sessions_send calls callGateway({ method: 'agent', params: sendParams })
with channel: INTER_SESSION_CHANNEL. Two things blocked the sentinel from
working in the real gateway path:
1. Channel hint validation rejected 'inter_session' as an unknown channel
(isGatewayMessageChannel returns false since we excluded it from
listGatewayMessageChannels). Fix: isKnownGatewayChannel now also passes
RESERVED_CHANNEL_IDS, letting internal sentinels through.
2. turnSourceMessageChannel discarded 'inter_session' because
isGatewayMessageChannel returns false for it, so ctx.OriginatingChannel
was never set to the sentinel and resolveLastChannelRaw never fired the
sentinel path. Fix: isInterSessionChannel check added alongside
isGatewayMessageChannel in the turnSourceMessageChannel assignment.
TypeScript TS2339 workaround: GatewayMessageChannel includes (string & {})
so after the type guard, value narrows to never. Captured as String(raw)
before the guard to preserve the plain string type.
INTER_SESSION_CHANNEL was added to listGatewayMessageChannels(), making
isGatewayMessageChannel('inter_session') return true. This allowed external
RPC callers to pass channel='inter_session', bypassing delivery validation
and triggering unintended inter-session routing behaviour.
Fix:
- Remove INTER_SESSION_CHANNEL from listGatewayMessageChannels() runtime
list. It remains in the GatewayMessageChannel type union so internal
tools (sessions-send-tool.ts) can still use it with type safety.
isGatewayMessageChannel('inter_session') now returns false.
- Add explicit RESERVED_CHANNEL_IDS guard in agent-delivery.ts before the
isGatewayMessageChannel branch. Belt-and-suspenders: even if the sentinel
somehow reaches the delivery resolver via an internal path, it falls back
to INTERNAL_MESSAGE_CHANNEL rather than being treated as a real channel.
The sentinel still functions correctly in resolveLastChannelRaw /
resolveLastToRaw, which call isInterSessionChannel() directly.
TypeScript infers Set<'inter_session' | 'webchat'> from the literal
values, making .has(string) a type error. Explicit Set<string>
annotation fixes CI typecheck without changing runtime behaviour.
registerChannel blocked reserved ids but not reserved aliases.
normalizeMessageChannel resolves aliases before routing, so a plugin
with alias 'inter_session' would remap the sentinel into a real
deliverable channel, bypassing resolveLastChannelRaw/resolveLastToRaw.
Fix: after the id guard, scan plugin.meta.aliases for reserved names.
Emit an error diagnostic and strip the reserved alias rather than
blocking the whole channel registration (so the rest of the plugin
still works). The reserved id set already covers both 'inter_session'
and 'webchat'.
Adds loader.test.ts case: plugin with reserved alias still registers
but alias is stripped and error diagnostic is emitted.
Codex P1: resolveLastToRaw's inter-session sentinel was unconditionally
returning persistedLastTo. When resolveLastChannelRaw falls back to a
session-key hint (e.g. agent:navi:discord:direct:* → 'discord') because
persistedLastChannel is not yet external (e.g. still 'webchat'), blindly
returning persistedLastTo creates a mismatched lastChannel/lastTo pair
(discord channel + stale webchat target), causing subsequent 'last'
replies to be routed to an invalid destination.
Fix: mirror resolveLastChannelRaw's sentinel logic — only preserve
persistedLastTo when persistedLastChannel is already an external routing
channel (consistent pair). Return undefined otherwise so the caller
derives an appropriate target from the resolved channel.
Adds two new tests:
- 'returns undefined when channel resolves via session-key hint'
- 'preserves persistedLastTo when persisted channel is already external'
21/21 session-delivery tests, 19/19 sessions tests, 23/23 install tests.
Codex P2: validatePluginId in install.ts only blocks the npm package
install path. A channel loaded via discovery/config can still call
registerChannel with id='inter_session', bypassing the guard.
- Move RESERVED_CHANNEL_IDS to message-channel.ts (canonical location
for channel constants) and export it
- Import RESERVED_CHANNEL_IDS in both install.ts and registry.ts
- Add guard in registerChannel: push error diagnostic and return early
if a plugin attempts to register a reserved channel ID
All three reservation paths are now covered:
1. Package install (validatePluginId in install.ts)
2. Runtime channel registration (registerChannel in registry.ts)
3. isInterSessionChannel deliverability guard (existing)
60/60 tests green.
Codex P2: a plugin named 'inter_session' would make
isInterSessionChannel return false (deliverable guard), silently
disabling the sentinel routing path in sessions_send.
Add RESERVED_CHANNEL_IDS set to validatePluginId() covering both
INTER_SESSION_CHANNEL and INTERNAL_MESSAGE_CHANNEL. Any attempt to
install a channel plugin with those ids now returns a clear error.
Adds regression test covering both sentinel ids.
Greptile: originatingChannel was computed before the isInterSessionChannel guard
but never used inside it, wasting a normalizeMessageChannel call on the fast path.
Moved the declaration to after the guard where it is actually needed.
Codex P2: isInterSessionChannel had no protection against a real deliverable plugin
channel named 'inter_session'. Added isDeliverableMessageChannel check so that if a
plugin registers that id, the sentinel path is skipped and normal routing applies.
Test: added invariant test documenting the Codex P2 guard (19 tests green).
Codex P2 on #43353: threading agentChannel alone without paired to/accountId/threadId
causes resolveLastToRaw to fall back to a stale persistedLastTo, producing mismatched
channel+to state (e.g. channel=discord but routing to an old Telegram destination).
Instead, use a new INTER_SESSION_CHANNEL ("inter_session") sentinel. Both
resolveLastChannelRaw and resolveLastToRaw handle it explicitly:
- resolveLastChannelRaw: returns the persisted external channel (or session-key hint)
without injecting the sender's channel or flipping to webchat
- resolveLastToRaw: returns persistedLastTo, preserving the receiver's destination
The sender's agentChannel is still captured in inputProvenance.sourceChannel for
observability but is no longer used to mutate the receiver's routing state.
Tests:
- sessions.test.ts: updated two agentChannel-threading tests to assert inter_session
sentinel is used (not webchat, not sender channel)
- session-delivery.test.ts: 6 new tests covering INTER_SESSION_CHANNEL routing cases
including the original webchat-flip regression and the stale-to preservation
Fixes https://github.com/openclaw/openclaw/issues/43318
Add two regression tests for #43318:
- asserts that when agentChannel is provided, the 'agent' gateway call
receives that channel (not 'webchat') in sendParams
- asserts that when agentChannel is absent, channel falls back to webchat
(preserving existing behaviour for internal spawns)
Closes#43318
When sessions_send injects a message into a target session, it was
hardcoding channel: INTERNAL_MESSAGE_CHANNEL ('webchat'). For main
sessions with an active external channel route (Discord, Telegram etc),
this caused resolveLastChannelRaw to flip lastChannel to 'webchat' for
that turn, silently routing the agent's reply to the control UI instead
of the user's channel.
Fix: pass opts?.agentChannel ?? INTERNAL_MESSAGE_CHANNEL so external-
originated A2A sends carry the correct source channel through. Falls
back to INTERNAL_MESSAGE_CHANNEL for internal spawns with no agentChannel.
Fixes: openclaw/openclaw#43318