diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e69f825c8..e08f1c463a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07. - OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky. - iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky and @Marvae. - iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky. diff --git a/src/agents/tools/cron-tool.flat-params.test.ts b/src/agents/tools/cron-tool.flat-params.test.ts new file mode 100644 index 0000000000..2a96b45107 --- /dev/null +++ b/src/agents/tools/cron-tool.flat-params.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +vi.mock("../agent-scope.js", () => ({ + resolveSessionAgentId: () => "agent-123", +})); + +import { createCronTool } from "./cron-tool.js"; + +describe("cron tool flat-params", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + callGatewayMock.mockResolvedValue({ ok: true }); + }); + + it("preserves explicit top-level sessionKey during flat-params recovery", async () => { + const tool = createCronTool({ agentSessionKey: "agent:main:discord:channel:ops" }); + await tool.execute("call-flat-session-key", { + action: "add", + sessionKey: "agent:main:telegram:group:-100123:topic:99", + schedule: { kind: "at", at: new Date(123).toISOString() }, + message: "do stuff", + }); + + const call = callGatewayMock.mock.calls[0]?.[0] as { + method?: string; + params?: { sessionKey?: string }; + }; + expect(call.method).toBe("cron.add"); + expect(call.params?.sessionKey).toBe("agent:main:telegram:group:-100123:topic:99"); + }); +}); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 3cae9b24f9..e977ed8302 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -299,6 +299,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con "description", "deleteAfterRun", "agentId", + "sessionKey", "message", "text", "model", diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts new file mode 100644 index 0000000000..1cbf93c625 --- /dev/null +++ b/src/gateway/server-cron.test.ts @@ -0,0 +1,81 @@ +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CliDeps } from "../cli/deps.js"; +import type { OpenClawConfig } from "../config/config.js"; + +const enqueueSystemEventMock = vi.fn(); +const requestHeartbeatNowMock = vi.fn(); +const loadConfigMock = vi.fn(); + +vi.mock("../infra/system-events.js", () => ({ + enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), +})); + +vi.mock("../infra/heartbeat-wake.js", () => ({ + requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args), +})); + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: () => loadConfigMock(), + }; +}); + +import { buildGatewayCronService } from "./server-cron.js"; + +describe("buildGatewayCronService", () => { + beforeEach(() => { + enqueueSystemEventMock.mockReset(); + requestHeartbeatNowMock.mockReset(); + loadConfigMock.mockReset(); + }); + + it("canonicalizes non-agent sessionKey to agent store key for enqueue + wake", async () => { + const tmpDir = path.join(os.tmpdir(), `server-cron-${Date.now()}`); + const cfg = { + session: { + mainKey: "main", + }, + cron: { + store: path.join(tmpDir, "cron.json"), + }, + } as OpenClawConfig; + loadConfigMock.mockReturnValue(cfg); + + const state = buildGatewayCronService({ + cfg, + deps: {} as CliDeps, + broadcast: () => {}, + }); + try { + const job = await state.cron.add({ + name: "canonicalize-session-key", + enabled: true, + schedule: { kind: "at", at: new Date(1).toISOString() }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + sessionKey: "discord:channel:ops", + payload: { kind: "systemEvent", text: "hello" }, + }); + + await state.cron.run(job.id, "force"); + + expect(enqueueSystemEventMock).toHaveBeenCalledWith( + "hello", + expect.objectContaining({ + sessionKey: "agent:main:discord:channel:ops", + }), + ); + expect(requestHeartbeatNowMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:discord:channel:ops", + }), + ); + } finally { + state.cron.stop(); + } + }); +}); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 68ba542394..cd0b565cb9 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -16,7 +16,7 @@ import { runHeartbeatOnce } from "../infra/heartbeat-runner.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; -import { normalizeAgentId } from "../routing/session-key.js"; +import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; export type GatewayCronState = { @@ -98,10 +98,15 @@ export function buildGatewayCronService(params: { agentId: params.agentId, }); } + const candidate = toAgentStoreSessionKey({ + agentId: params.agentId, + requestKey: requested, + mainKey: params.runtimeConfig.session?.mainKey, + }); const canonical = canonicalizeMainSessionAlias({ cfg: params.runtimeConfig, agentId: params.agentId, - sessionKey: requested, + sessionKey: candidate, }); if (canonical !== "global") { const sessionAgentId = resolveAgentIdFromSessionKey(canonical);