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