From caecd3c1fe330e745c97dbb977eb1bbae2ae2ec7 Mon Sep 17 00:00:00 2001 From: EVA Date: Tue, 7 Apr 2026 23:59:18 +0700 Subject: [PATCH] =?UTF-8?q?fix(agents):=20heartbeat=20always=20targets=20m?= =?UTF-8?q?ain=20session=20=E2=80=94=20prevent=20routing=20to=20active=20s?= =?UTF-8?q?ubagent=20sessions=20(#61803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged via squash. Prepared head SHA: 5d79db3940b4cc1dedde5a8dc674c15b70622fa3 Co-authored-by: 100yenadmin <239388517+100yenadmin@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 2 +- ...tbeat-runner.returns-default-unset.test.ts | 92 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 760bab5ea93..8c85d61b44b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,7 +82,7 @@ Docs: https://docs.openclaw.ai - Providers/Mistral: send `reasoning_effort` for `mistral/mistral-small-latest` (Mistral Small 4) with thinking-level mapping, and mark the catalog entry as reasoning-capable so adjustable reasoning matches Mistral’s Chat Completions API. (#62162) Thanks @neeravmakwana. - OpenAI TTS/Groq: send `wav` to Groq-compatible speech endpoints, honor explicit `responseFormat` overrides on OpenAI-compatible paths, and only mark voice-note output as voice-compatible when the actual format is `opus`. (#62233) Thanks @neeravmakwana. - BlueBubbles/network: respect explicit private-network opt-out for loopback and private `serverUrl` values across account resolution, status probes, monitor startup, and attachment downloads, while keeping public-host attachment hostname pinning intact. (#59373) Thanks @jpreagan. - +- Agents/heartbeat: keep heartbeat runs pinned to the main session so active subagent transcripts are not overwritten by heartbeat status messages. (#61803) thanks @100yenadmin. ## 2026.4.5 ### Breaking diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 4ce588e2166..a49b03214cc 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -898,6 +898,98 @@ describe("runHeartbeatOnce", () => { }, ); + it.each([ + { + name: "subagent key via forcedSessionKey (opts.sessionKey)", + injectVia: "opts" as const, + }, + { + name: "subagent key via heartbeat.session config", + injectVia: "config" as const, + }, + ])("falls back to main session when subagent key enters via $name", async ({ injectVia }) => { + const replySpy = vi.fn(); + try { + const tmpDir = await createCaseDir("hb-subagent-guard"); + const storePath = path.join(tmpDir, "sessions.json"); + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { + every: "5m", + target: "last", + }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const mainSessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(mainSessionKey); + const subagentKey = `agent:${agentId}:subagent:task-abc`; + + if (injectVia === "config" && cfg.agents?.defaults?.heartbeat) { + cfg.agents.defaults.heartbeat.session = subagentKey; + } + + await fs.writeFile( + storePath, + JSON.stringify({ + [mainSessionKey]: { + sessionId: "sid-main", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "120363401234567890@g.us", + }, + [subagentKey]: { + sessionId: "sid-subagent", + updatedAt: Date.now() + 10_000, + lastChannel: "whatsapp", + lastTo: "99999@g.us", + }, + }), + ); + + replySpy.mockClear(); + replySpy.mockResolvedValue([{ text: "Main session heartbeat" }]); + const sendWhatsApp = vi + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() + .mockResolvedValue({ messageId: "m1", toJid: "jid" }); + + await runHeartbeatOnce({ + cfg, + ...(injectVia === "opts" ? { sessionKey: subagentKey } : {}), + deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }), + }); + + // The heartbeat must use the main session, not the subagent session. + expect(replySpy).toHaveBeenCalledWith( + expect.objectContaining({ + SessionKey: mainSessionKey, + }), + expect.anything(), + expect.anything(), + ); + // Must NOT use the subagent session key. + expect(replySpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + SessionKey: subagentKey, + }), + expect.anything(), + expect.anything(), + ); + } finally { + replySpy.mockReset(); + } + }); + it("suppresses duplicate heartbeat payloads within 24h", async () => { const tmpDir = await createCaseDir("hb-dup-suppress"); const storePath = path.join(tmpDir, "sessions.json");