fix(heartbeat): relay exec-event responses to last session channel when target is none

Ensure async exec completion messages are delivered back to the originating session
channel even when heartbeat target is configured as "none".

- add `forceLastTargetWhenNone` to heartbeat delivery target resolution
- enable the fallback for exec-event runs in heartbeat runner
- update target-none exec test to assert delivery to last channel

This keeps cron reminders silent under target=none, while restoring expected
exec result routing to the original channel (e.g. Discord).
This commit is contained in:
eviaaaaa 2026-03-07 00:39:25 +08:00 committed by Kaspre
parent fc6fde280f
commit f7d8b28d21
4 changed files with 30 additions and 11 deletions

View File

@ -216,10 +216,10 @@ describe("Ghost reminder bug (issue #13317)", () => {
expect(sendTelegram).not.toHaveBeenCalled();
});
it("uses an internal-only exec prompt when delivery target is none", async () => {
it("relays exec prompt to last channel when delivery target is none", async () => {
const { result, sendTelegram, calledCtx } = await runHeartbeatCase({
tmpPrefix: "openclaw-exec-internal-",
replyText: "Handled internally",
replyText: "Exec completed successfully",
reason: "exec-event",
target: "none",
enqueue: (sessionKey) => {
@ -229,7 +229,8 @@ describe("Ghost reminder bug (issue #13317)", () => {
expect(result.status).toBe("ran");
expect(calledCtx?.Provider).toBe("exec-event");
expect(calledCtx?.Body).toContain("Handle the result internally");
expect(sendTelegram).not.toHaveBeenCalled();
expect(calledCtx?.Body).toContain("Please relay the command output to the user");
expect(calledCtx?.Body).not.toContain("Handle the result internally");
expect(sendTelegram).toHaveBeenCalled();
});
});

View File

@ -1332,7 +1332,7 @@ describe("runHeartbeatOnce", () => {
}
});
it("uses an internal-only exec prompt when heartbeat delivery target is none", async () => {
it("relays exec completion to last channel when heartbeat delivery target is none", async () => {
const tmpDir = await createCaseDir("hb-exec-target-none");
const storePath = path.join(tmpDir, "sessions.json");
const cfg: OpenClawConfig = {
@ -1363,7 +1363,7 @@ describe("runHeartbeatOnce", () => {
});
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
replySpy.mockResolvedValue({ text: "Handled internally" });
replySpy.mockResolvedValue({ text: "Exec completed successfully" });
const sendWhatsApp = vi
.fn<
(to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }>
@ -1377,11 +1377,16 @@ describe("runHeartbeatOnce", () => {
deps: createHeartbeatDeps(sendWhatsApp),
});
expect(res.status).toBe("ran");
expect(sendWhatsApp).toHaveBeenCalledTimes(0);
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
expect(sendWhatsApp).toHaveBeenCalledWith(
"120363401234567890@g.us",
"Exec completed successfully",
expect.objectContaining({ accountId: undefined }),
);
const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string };
expect(calledCtx.Provider).toBe("exec-event");
expect(calledCtx.Body).toContain("Handle the result internally");
expect(calledCtx.Body).not.toContain("Please relay the command output to the user");
expect(calledCtx.Body).toContain("Please relay the command output to the user");
expect(calledCtx.Body).not.toContain("Handle the result internally");
} finally {
replySpy.mockRestore();
}

View File

@ -598,7 +598,12 @@ export async function runHeartbeatOnce(opts: {
runStorePath = cronSession.storePath;
}
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
const delivery = resolveHeartbeatDeliveryTarget({
cfg,
entry,
heartbeat,
forceLastTargetWhenNone: preflight.isExecEventReason,
});
const heartbeatAccountId = heartbeat?.accountId?.trim();
if (delivery.reason === "unknown-account") {
log.warn("heartbeat: unknown accountId", {

View File

@ -254,9 +254,11 @@ export function resolveHeartbeatDeliveryTarget(params: {
cfg: OpenClawConfig;
entry?: SessionEntry;
heartbeat?: AgentDefaultsConfig["heartbeat"];
forceLastTargetWhenNone?: boolean;
}): OutboundTarget {
const { cfg, entry } = params;
const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat;
const forceLastTargetWhenNone = params.forceLastTargetWhenNone === true;
const rawTarget = heartbeat?.target;
let target: HeartbeatTarget = "none";
if (rawTarget === "none" || rawTarget === "last") {
@ -268,7 +270,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
}
if (target === "none") {
if (target === "none" && !forceLastTargetWhenNone) {
const base = resolveSessionDeliveryTarget({ entry });
return buildNoHeartbeatDeliveryTarget({
reason: "target-none",
@ -277,6 +279,12 @@ export function resolveHeartbeatDeliveryTarget(params: {
});
}
// For async exec completion events, always fall back to the session's last
// delivery target even when heartbeat target is explicitly set to "none".
if (target === "none" && forceLastTargetWhenNone) {
target = "last";
}
const resolvedTarget = resolveSessionDeliveryTarget({
entry,
requestedChannel: target === "last" ? "last" : target,