diff --git a/CHANGELOG.md b/CHANGELOG.md index 1911a75bda1..2a6e97310ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin. - Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent `RangeError: Invalid string length` on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888. - iMessage/cron completion announces: strip leaked inline reply tags (for example `[[reply_to:6100]]`) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc. +- Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt. - Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker. - Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai. - Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing `thinking`/`text` strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin. diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 78bace08dbc..cb71c9b09ba 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -399,6 +399,58 @@ describe("dispatchReplyFromConfig", () => { expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); + it("does not route external origin replies when current surface is internal webchat without explicit delivery", async () => { + setNoAbort(); + mocks.routeReply.mockClear(); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "webchat", + Surface: "webchat", + OriginatingChannel: "imessage", + OriginatingTo: "imessage:+15550001111", + }); + + const replyResolver = async ( + _ctx: MsgContext, + _opts?: GetReplyOptions, + _cfg?: OpenClawConfig, + ) => ({ text: "hi" }) satisfies ReplyPayload; + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(mocks.routeReply).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); + }); + + it("routes external origin replies for internal webchat turns when explicit delivery is set", async () => { + setNoAbort(); + mocks.routeReply.mockClear(); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "webchat", + Surface: "webchat", + OriginatingChannel: "imessage", + OriginatingTo: "imessage:+15550001111", + ExplicitDeliverRoute: true, + }); + + const replyResolver = async ( + _ctx: MsgContext, + _opts?: GetReplyOptions, + _cfg?: OpenClawConfig, + ) => ({ text: "hi" }) satisfies ReplyPayload; + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + expect(mocks.routeReply).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "imessage", + to: "imessage:+15550001111", + }), + ); + }); + it("routes media-only tool results when summaries are suppressed", async () => { setNoAbort(); mocks.routeReply.mockClear(); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 1a968581cf6..003a8f37435 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -215,8 +215,15 @@ export async function dispatchReplyFromConfig(params: { const surfaceChannel = normalizeMessageChannel(ctx.Surface); // Prefer provider channel because surface may carry origin metadata in relayed flows. const currentSurface = providerChannel ?? surfaceChannel; + const isInternalWebchatTurn = + currentSurface === INTERNAL_MESSAGE_CHANNEL && + (surfaceChannel === INTERNAL_MESSAGE_CHANNEL || !surfaceChannel) && + ctx.ExplicitDeliverRoute !== true; const shouldRouteToOriginating = Boolean( - isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface, + !isInternalWebchatTurn && + isRoutableChannel(originatingChannel) && + originatingTo && + originatingChannel !== currentSurface, ); const shouldSuppressTyping = shouldRouteToOriginating || originatingChannel === INTERNAL_MESSAGE_CHANNEL; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 9c9a7f4d430..ae6a7917ff8 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -159,6 +159,11 @@ export type MsgContext = { * The chat/channel/user ID where the reply should be sent. */ OriginatingTo?: string; + /** + * True when the current turn intentionally requested external delivery to + * OriginatingChannel/OriginatingTo, rather than inheriting stale session route metadata. + */ + ExplicitDeliverRoute?: boolean; /** * Provider-specific parent conversation id for threaded contexts. * For Discord threads, this is the parent channel id. diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index f9acd15805e..d4f631a21ce 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -393,6 +393,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect.objectContaining({ OriginatingChannel: "telegram", OriginatingTo: "telegram:6812765697", + ExplicitDeliverRoute: true, AccountId: "default", MessageThreadId: 42, }), @@ -566,6 +567,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect.objectContaining({ OriginatingChannel: "webchat", OriginatingTo: undefined, + ExplicitDeliverRoute: false, AccountId: undefined, }), ); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 1c750ec0db6..18c00f11118 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -935,6 +935,7 @@ export const chatHandlers: GatewayRequestHandlers = { Surface: INTERNAL_MESSAGE_CHANNEL, OriginatingChannel: originatingChannel, OriginatingTo: originatingTo, + ExplicitDeliverRoute: hasDeliverableRoute, AccountId: accountId, MessageThreadId: messageThreadId, ChatType: "direct",