diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 9af9b1ab826..d290d31d955 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -713,6 +713,43 @@ describe("dispatchReplyFromConfig", () => { ); }); + it("routes exec-event replies using persisted session delivery context when current turn has no originating route", async () => { + setNoAbort(); + mocks.routeReply.mockClear(); + sessionStoreMocks.currentEntry = { + deliveryContext: { + channel: "telegram", + to: "telegram:999", + accountId: "acc-1", + }, + lastChannel: "telegram", + lastTo: "telegram:999", + lastAccountId: "acc-1", + }; + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "exec-event", + Surface: "exec-event", + SessionKey: "agent:main:main", + AccountId: undefined, + OriginatingChannel: undefined, + OriginatingTo: undefined, + }); + + const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + expect(mocks.routeReply).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "telegram:999", + accountId: "acc-1", + }), + ); + }); + it("falls back to thread-scoped session key when current ctx has no MessageThreadId", 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 fb4a75bc5f0..e7e0ea6af37 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -287,6 +287,18 @@ export async function dispatchReplyFromConfig( "", ) ?? "off", }); + const isSystemEventTurn = + ctx.Provider === "heartbeat" || ctx.Provider === "cron-event" || ctx.Provider === "exec-event"; + const persistedDeliveryContext = sessionStoreEntry.entry?.deliveryContext; + const fallbackOriginatingChannel = isSystemEventTurn + ? (persistedDeliveryContext?.channel ?? sessionStoreEntry.entry?.lastChannel) + : undefined; + const fallbackOriginatingTo = isSystemEventTurn + ? (persistedDeliveryContext?.to ?? sessionStoreEntry.entry?.lastTo) + : undefined; + const fallbackOriginatingAccountId = isSystemEventTurn + ? (persistedDeliveryContext?.accountId ?? sessionStoreEntry.entry?.lastAccountId) + : undefined; // Restore route thread context only from the active turn or the thread-scoped session key. // Do not read thread ids from the normalised session store here: `origin.threadId` can be // folded back into lastThreadId/deliveryContext during store normalisation and resurrect a @@ -319,7 +331,10 @@ export async function dispatchReplyFromConfig( // // Debug: `pnpm test src/auto-reply/reply/dispatch-from-config.test.ts` const suppressAcpChildUserDelivery = isParentOwnedBackgroundAcpSession(sessionStoreEntry.entry); - const normalizedOriginatingChannel = normalizeMessageChannel(ctx.OriginatingChannel); + const effectiveOriginatingChannel = ctx.OriginatingChannel ?? fallbackOriginatingChannel; + const effectiveOriginatingTo = ctx.OriginatingTo ?? fallbackOriginatingTo; + const routeAccountId = ctx.AccountId ?? fallbackOriginatingAccountId; + const normalizedOriginatingChannel = normalizeMessageChannel(effectiveOriginatingChannel); const normalizedProviderChannel = normalizeMessageChannel(ctx.Provider); const normalizedSurfaceChannel = normalizeMessageChannel(ctx.Surface); const normalizedCurrentSurface = normalizedProviderChannel ?? normalizedSurfaceChannel; @@ -331,7 +346,7 @@ export async function dispatchReplyFromConfig( !suppressAcpChildUserDelivery && !isInternalWebchatTurn && normalizedOriginatingChannel && - ctx.OriginatingTo && + effectiveOriginatingTo && normalizedOriginatingChannel !== normalizedCurrentSurface, ); const routeReplyRuntime = hasRouteReplyCandidate ? await loadRouteReplyRuntime() : undefined; @@ -340,12 +355,12 @@ export async function dispatchReplyFromConfig( provider: ctx.Provider, surface: ctx.Surface, explicitDeliverRoute: ctx.ExplicitDeliverRoute, - originatingChannel: ctx.OriginatingChannel, - originatingTo: ctx.OriginatingTo, + originatingChannel: effectiveOriginatingChannel, + originatingTo: effectiveOriginatingTo, suppressDirectUserDelivery: suppressAcpChildUserDelivery, isRoutableChannel: routeReplyRuntime?.isRoutableChannel ?? (() => false), }); - const originatingTo = ctx.OriginatingTo; + const originatingTo = effectiveOriginatingTo; const ttsChannel = shouldRouteToOriginating ? originatingChannel : currentSurface; const { createReplyMediaPathNormalizer } = await loadReplyMediaPathsRuntime(); const normalizeReplyMediaPaths = createReplyMediaPathNormalizer({ @@ -353,7 +368,7 @@ export async function dispatchReplyFromConfig( sessionKey: acpDispatchSessionKey, workspaceDir: resolveAgentWorkspaceDir(cfg, sessionAgentId), messageProvider: ttsChannel, - accountId: ctx.AccountId, + accountId: routeAccountId, groupId, groupChannel: ctx.GroupChannel, groupSpace: ctx.GroupSpace, @@ -385,7 +400,7 @@ export async function dispatchReplyFromConfig( ctx.CommandSource === "native" ? (ctx.CommandTargetSessionKey ?? ctx.SessionKey) : ctx.SessionKey, - accountId: ctx.AccountId, + accountId: routeAccountId, requesterSenderId: ctx.SenderId, requesterSenderName: ctx.SenderName, requesterSenderUsername: ctx.SenderUsername,