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:
parent
fc6fde280f
commit
f7d8b28d21
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user