Do not recover route thread ids from the normalised session store in
non-inbound reply paths. Store normalisation can fold origin.threadId
back into lastThreadId/deliveryContext, which resurrects stale thread
routing after delivery was intentionally cleared.
Instead, restore thread context only from:
- ctx.MessageThreadId (active inbound turn), or
- the active thread-scoped session key (🧵 / :topic:)
Also updates dispatch tests to verify that stale origin/store thread
metadata cannot override a non-thread session key, while a thread-scoped
session key still restores the correct route thread.
lastThreadId is normalised from origin.threadId by loadSessionStore, so
using it as a fallback here would re-acquire the same stale thread that
deliveryContext.threadId was intentionally cleared from. Only
deliveryContext.threadId is safe to use as the restored route thread.
Also strengthens the regression test to include the normalised
lastThreadId value that the real store produces, so the guard is
verified against the actual production data shape.
Addresses review feedback from chatgpt-codex-connector.
Fixes a bug where replies triggered from TUI/WebUI or by agent-initiated
sends (tool callbacks, subagent responses, message tool) land in the
Mattermost channel root instead of the originating thread.
Root cause: three gaps in the outbound routing path for turns not
directly triggered by an inbound Mattermost message:
1. dispatch-from-config.ts: sendPayloadAsync passed ctx.MessageThreadId,
which is undefined for webchat/TUI turns. Now falls back to the
session entry's deliveryContext.threadId (the lastThreadId stored when
the session was first created from an inbound Mattermost message).
2. route-reply.ts: threadId was only forwarded as replyToId for Slack.
Mattermost uses the same root_id mechanic, so the same fallback now
applies to Mattermost too.
3. channel.ts (Mattermost outbound): sendText/sendMedia only consumed
replyToId, ignoring threadId. Added threadId as a defense-in-depth
fallback for any path that sets threadId but not replyToId.
All three gaps in a single PR. Tests added for each fix path.
Fixes#39759
* fix(hooks): include guildId and channelName in message_received metadata
The message_received hook (both plugin and internal) already exposes
sender identity fields (senderId, senderName, senderUsername, senderE164)
but omits the guild/channel context. Plugins that track per-channel
activity receive NULL values for channel identification.
Add guildId (ctx.GroupSpace) and channelName (ctx.GroupChannel) to the
metadata block in both the plugin hook and internal hook dispatch paths.
These properties are already populated by channel providers (e.g. Discord
sets GroupSpace to the guild ID and GroupChannel to #channel-name) and
used elsewhere in the codebase (channels/conversation-label.ts).
* test: cover guild/channel hook metadata propagation (#26115) (thanks @davidrudduck)
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
When reasoningLevel is 'on', reasoning content was being sent as a
visible message to WhatsApp and other non-Telegram channels via two
paths:
1. Block reply: emitted via onBlockReply in handleMessageEnd
2. Final payloads: added to replyItems in buildEmbeddedRunPayloads
Telegram has its own dispatch path (bot-message-dispatch.ts) that
splits reasoning into a dedicated lane and handles suppression.
The generic dispatch-from-config.ts path used by WhatsApp, web, etc.
had no such filtering.
Fix:
- Add isReasoning?: boolean flag to ReplyPayload
- Tag reasoning payloads at both emission points
- Filter isReasoning payloads in dispatch-from-config.ts for both
block reply and final reply paths
Telegram is unaffected: it uses its own deliver callback that detects
reasoning via the 'Reasoning:\n' prefix and routes to a separate lane.
Fixes#24954
* fix: defer gateway restart until all replies are sent
Fixes a race condition where gateway config changes (e.g., enabling
plugins via iMessage) trigger an immediate SIGUSR1 restart, killing the
iMessage RPC connection before replies are delivered.
Both restart paths (config watcher and RPC-triggered) now defer until
all queued operations, pending replies, and embedded agent runs complete
(polling every 500ms, 30s timeout). A shared emitGatewayRestart() guard
prevents double SIGUSR1 when both paths fire simultaneously.
Key changes:
- Dispatcher registry tracks active reply dispatchers globally
- markComplete() called in finally block for guaranteed cleanup
- Pre-restart deferral hook registered at gateway startup
- Centralized extractDeliveryInfo() for session key parsing
- Post-restart sentinel messages delivered directly (not via agent)
- config-patch distinguished from config-apply in sentinel kind
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: single-source gateway restart authorization
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Native slash commands (e.g. /verbose, /status) should not emit tool
summaries. Gate onToolResult behind CommandSource !== 'native' in
addition to the existing ChatType !== 'group' check.
Add test for native command exclusion.
- provides onToolResult in DM sessions (ChatType=direct)
- does not provide onToolResult in group sessions (ChatType=group)
- sends tool results via dispatcher in DM sessions
Replaces the old cross-provider test that expected onToolResult to
always be undefined.