diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index f215b8313d1..04b739f2066 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -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(); }); }); diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 8bca1ca1de7..71b12f20feb 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -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(); } diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 5e6ddcf07cf..8364c0dbf81 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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", { diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 2d294efbef9..6863430153b 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -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,