From f7de41ca20d7de34f0aa0e410ac58c8b344a981e Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 25 Feb 2026 12:52:08 +0800 Subject: [PATCH] fix(followup): fall back to dispatcher when same-channel origin routing fails (#26109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(followup): fall back to dispatcher when same-channel origin routing fails When routeReply fails for an originating channel that matches the session's messageProvider, the onBlockReply callback was created by that same channel's handler and can safely deliver the reply. Previously the payload was silently dropped on any routeReply failure, causing Feishu DM replies to never reach the user. Cross-channel fallback (origin ≠ provider) still drops the payload to preserve origin isolation. Closes #25767 Co-authored-by: Cursor * fix: allow same-channel followup fallback routing (#26109) (thanks @Sid-Qin) --------- Co-authored-by: Cursor Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/auto-reply/reply/followup-runner.test.ts | 42 +++++++++++++++++++- src/auto-reply/reply/followup-runner.ts | 17 +++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ec48ff2..fb1208004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) +- Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. ## 2026.2.24 diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 7627c79a5..da5d55fa9 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -8,6 +8,7 @@ import { createMockTypingController } from "./test-helpers.js"; const runEmbeddedPiAgentMock = vi.fn(); const routeReplyMock = vi.fn(); +const isRoutableChannelMock = vi.fn(); vi.mock( "../../agents/model-fallback.js", @@ -22,15 +23,30 @@ vi.mock("./route-reply.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + isRoutableChannel: (...args: unknown[]) => isRoutableChannelMock(...args), routeReply: (...args: unknown[]) => routeReplyMock(...args), }; }); import { createFollowupRunner } from "./followup-runner.js"; +const ROUTABLE_TEST_CHANNELS = new Set([ + "telegram", + "slack", + "discord", + "signal", + "imessage", + "whatsapp", + "feishu", +]); + beforeEach(() => { routeReplyMock.mockReset(); routeReplyMock.mockResolvedValue({ ok: true }); + isRoutableChannelMock.mockReset(); + isRoutableChannelMock.mockImplementation((ch: string | undefined) => + Boolean(ch?.trim() && ROUTABLE_TEST_CHANNELS.has(ch.trim().toLowerCase())), + ); }); const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun => @@ -336,7 +352,7 @@ describe("createFollowupRunner messaging tool dedupe", () => { expect(store[sessionKey]?.outputTokens).toBe(50); }); - it("does not fall back to dispatcher when explicit origin routing fails", async () => { + it("does not fall back to dispatcher when cross-channel origin routing fails", async () => { const onBlockReply = vi.fn(async () => {}); runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hello world!" }], @@ -359,6 +375,30 @@ describe("createFollowupRunner messaging tool dedupe", () => { expect(onBlockReply).not.toHaveBeenCalled(); }); + it("falls back to dispatcher when same-channel origin routing fails", async () => { + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + meta: {}, + }); + routeReplyMock.mockResolvedValueOnce({ + ok: false, + error: "outbound adapter unavailable", + }); + + const runner = createMessagingDedupeRunner(onBlockReply); + + await runner({ + ...baseQueuedRun(" Feishu "), + originatingChannel: "FEISHU", + originatingTo: "ou_abc123", + } as FollowupRun); + + expect(routeReplyMock).toHaveBeenCalled(); + expect(onBlockReply).toHaveBeenCalledTimes(1); + expect(onBlockReply).toHaveBeenCalledWith(expect.objectContaining({ text: "hello world!" })); + }); + it("routes followups with originating account/thread metadata", async () => { const onBlockReply = vi.fn(async () => {}); runEmbeddedPiAgentMock.mockResolvedValueOnce({ diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index ba78b7abf..0c91d543d 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -103,10 +103,23 @@ export function createFollowupRunner(params: { cfg: queued.run.config, }); if (!result.ok) { - // Keep origin isolation strict: do not fall back to the current - // dispatcher when explicit origin routing failed. const errorMsg = result.error ?? "unknown error"; logVerbose(`followup queue: route-reply failed: ${errorMsg}`); + // Fall back to the caller-provided dispatcher only when the + // originating channel matches the session's message provider. + // In that case onBlockReply was created by the same channel's + // handler and delivers to the correct destination. For true + // cross-channel routing (origin !== provider), falling back + // would send to the wrong channel, so we drop the payload. + const provider = resolveOriginMessageProvider({ + provider: queued.run.messageProvider, + }); + const origin = resolveOriginMessageProvider({ + originatingChannel, + }); + if (opts?.onBlockReply && origin && origin === provider) { + await opts.onBlockReply(payload); + } } } else if (opts?.onBlockReply) { await opts.onBlockReply(payload);