fix(auto-reply): route exec-event replies via persisted delivery context

This commit is contained in:
wzfukui
2026-04-22 23:55:50 +08:00
committed by Peter Steinberger
parent aad31817ef
commit 87a08dd4c2
2 changed files with 59 additions and 7 deletions

View File

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

View File

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