Fix Control UI duplicate iMessage replies for internal webchat turns (#36151)

* Auto-reply: avoid routing external replies from internal webchat turns

* Auto-reply tests: cover internal webchat non-routing with external origin metadata

* Changelog: add Control UI iMessage duplicate-reply fix note

* Auto-reply context: track explicit deliver routes

* Gateway chat: mark explicit external deliver routes in context

* Auto-reply: preserve explicit deliver routes for internal webchat turns

* Auto-reply tests: cover explicit deliver routes from internal webchat turns

* Gateway chat tests: assert explicit deliver route context tagging
This commit is contained in:
Vincent Koc
2026-03-06 00:47:57 -05:00
committed by GitHub
parent 8c2633a46f
commit 6c39616ecd
6 changed files with 69 additions and 1 deletions

View File

@@ -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();

View File

@@ -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;