From e309fd485e34cc56fd6d584605cb547feb7c90b0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 02:10:35 +0100 Subject: [PATCH] fix(cron): preserve current delivery target context --- CHANGELOG.md | 3 + src/agents/openclaw-tools.ts | 6 + src/agents/openclaw-tools.tts-config.test.ts | 39 +++++- src/agents/tools/cron-tool.test.ts | 118 ++++++++++++++++++- src/agents/tools/cron-tool.ts | 32 ++++- 5 files changed, 193 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8a843e173..af2d0724999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,9 @@ Docs: https://docs.openclaw.ai - Models/LM Studio: preserve `@iq*` quant suffixes in model refs and provider matching so `/model lmstudio/...@iq3_xxs` keeps the exact LM Studio variant. Fixes #71474. (#71486) Thanks @Bartok9, @XinwuC, and @Sanjays2402. +- Matrix/cron: preserve the live Matrix delivery target when creating implicit + announce reminder jobs so mixed-case room IDs are not reconstructed from + lowercased session keys. Fixes #71798. - Feishu: accept Schema 2.0 card action callbacks that report `context.open_chat_id` instead of legacy `context.chat_id`, so button callbacks no longer drop as malformed. Fixes #71670. Thanks @eddy1068. diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 23fab6de195..dae4583c87e 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -241,6 +241,12 @@ export function createOpenClawTools( nodesTool, createCronTool({ agentSessionKey: options?.agentSessionKey, + currentDeliveryContext: { + channel: options?.agentChannel, + to: options?.currentChannelId ?? options?.agentTo, + accountId: options?.agentAccountId, + threadId: options?.currentThreadTs ?? options?.agentThreadId, + }, }), ]), ...(!embedded && messageTool ? [messageTool] : []), diff --git a/src/agents/openclaw-tools.tts-config.test.ts b/src/agents/openclaw-tools.tts-config.test.ts index 7ba078480ef..3ee8ccff9c0 100644 --- a/src/agents/openclaw-tools.tts-config.test.ts +++ b/src/agents/openclaw-tools.tts-config.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => { return { stubTool, + createCronToolOptions: vi.fn(), textToSpeech: vi.fn(async () => ({ success: true, audioPath: "/tmp/openclaw/tts-config-test.opus", @@ -41,7 +42,10 @@ vi.mock("./tools/canvas-tool.js", () => ({ })); vi.mock("./tools/cron-tool.js", () => ({ - createCronTool: () => mocks.stubTool("cron"), + createCronTool: (options: unknown) => { + mocks.createCronToolOptions(options); + return mocks.stubTool("cron"); + }, })); vi.mock("./tools/gateway-tool.js", () => ({ @@ -119,6 +123,7 @@ vi.mock("../tts/tts.js", () => ({ describe("createOpenClawTools TTS config wiring", () => { beforeEach(() => { + mocks.createCronToolOptions.mockClear(); mocks.textToSpeech.mockClear(); }); @@ -163,3 +168,35 @@ describe("createOpenClawTools TTS config wiring", () => { } }); }); + +describe("createOpenClawTools cron context wiring", () => { + beforeEach(() => { + mocks.createCronToolOptions.mockClear(); + }); + + it("passes preserved channel delivery context into the cron tool", async () => { + const { createOpenClawTools } = await import("./openclaw-tools.js"); + + createOpenClawTools({ + agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org", + agentChannel: "matrix", + agentAccountId: "bot-a", + agentTo: "room:!FallbackRoom:Example.Org", + agentThreadId: "$FallbackThread:Example.Org", + currentChannelId: "room:!AbCdEf1234567890:example.org", + currentThreadTs: "$RootEvent:Example.Org", + disableMessageTool: true, + disablePluginTools: true, + }); + + expect(mocks.createCronToolOptions).toHaveBeenCalledWith({ + agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org", + currentDeliveryContext: { + channel: "matrix", + to: "room:!AbCdEf1234567890:example.org", + accountId: "bot-a", + threadId: "$RootEvent:Example.Org", + }, + }); + }); +}); diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index ae304d2f6b6..57dd09da763 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -60,10 +60,16 @@ describe("cron tool", () => { async function executeAddAndReadDelivery(params: { callId: string; - agentSessionKey: string; + agentSessionKey?: string; + currentDeliveryContext?: NonNullable< + Parameters[0] + >["currentDeliveryContext"]; delivery?: { mode?: string; channel?: string; to?: string } | null; }) { - const tool = createTestCronTool({ agentSessionKey: params.agentSessionKey }); + const tool = createTestCronTool({ + agentSessionKey: params.agentSessionKey, + currentDeliveryContext: params.currentDeliveryContext, + }); await tool.execute(params.callId, { action: "add", job: { @@ -415,6 +421,114 @@ describe("cron tool", () => { }); }); + it("prefers current delivery context over lowercased session-key targets", async () => { + expect( + await executeAddAndReadDelivery({ + callId: "call-current-context", + agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org", + currentDeliveryContext: { + channel: "matrix", + to: "room:!AbCdEf1234567890:example.org", + accountId: "bot-a", + threadId: "$RootEvent:Example.Org", + }, + }), + ).toEqual({ + mode: "announce", + channel: "matrix", + to: "room:!AbCdEf1234567890:example.org", + accountId: "bot-a", + threadId: "$RootEvent:Example.Org", + }); + }); + + it("does not let current delivery context override explicit delivery targets", async () => { + expect( + await executeAddAndReadDelivery({ + callId: "call-explicit-target-wins", + agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org", + currentDeliveryContext: { + channel: "matrix", + to: "room:!AbCdEf1234567890:example.org", + }, + delivery: { + mode: "announce", + channel: "telegram", + to: "-100123", + }, + }), + ).toEqual({ + mode: "announce", + channel: "telegram", + to: "-100123", + }); + }); + + it("infers delivery from current context even when no session key is available", async () => { + expect( + await executeAddAndReadDelivery({ + callId: "call-context-no-session", + currentDeliveryContext: { + channel: "matrix", + to: "!AbCdEf1234567890:example.org", + }, + }), + ).toEqual({ + mode: "announce", + channel: "matrix", + to: "!AbCdEf1234567890:example.org", + }); + }); + + it("uses current delivery context when delivery is null", async () => { + expect( + await executeAddAndReadDelivery({ + callId: "call-null-delivery-current-context", + agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org", + currentDeliveryContext: { + channel: "matrix", + to: "!AbCdEf1234567890:example.org", + }, + delivery: null, + }), + ).toEqual({ + mode: "announce", + channel: "matrix", + to: "!AbCdEf1234567890:example.org", + }); + }); + + it("falls back to session-key inference when current context has no target", async () => { + expect( + await executeAddAndReadDelivery({ + callId: "call-empty-current-context", + agentSessionKey: "agent:main:telegram:group:-1001234567890:topic:99", + currentDeliveryContext: { + channel: "matrix", + to: " ", + }, + }), + ).toEqual({ + mode: "announce", + channel: "telegram", + to: "-1001234567890:topic:99", + }); + }); + + it("does not infer current delivery context when delivery mode is none", async () => { + expect( + await executeAddAndReadDelivery({ + callId: "call-current-context-mode-none", + agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org", + currentDeliveryContext: { + channel: "matrix", + to: "!AbCdEf1234567890:example.org", + }, + delivery: { mode: "none" }, + }), + ).toEqual({ mode: "none" }); + }); + it("infers delivery when delivery is null", async () => { expect( await executeAddAndReadDelivery({ diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 00ed4f4d754..1bf4af5bdb7 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -10,6 +10,10 @@ import { normalizeOptionalLowercaseString, } from "../../shared/string-coerce.js"; import { isRecord, truncateUtf16Safe } from "../../utils.js"; +import { + normalizeDeliveryContext, + type DeliveryContext, +} from "../../utils/delivery-context.shared.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { CRON_TOOL_DISPLAY_SUMMARY } from "../tool-description-presets.js"; @@ -287,6 +291,7 @@ export const CronToolSchema = Type.Object( type CronToolOptions = { agentSessionKey?: string; + currentDeliveryContext?: DeliveryContext; }; type GatewayToolCaller = typeof callGatewayTool; @@ -443,6 +448,27 @@ function inferDeliveryFromSessionKey(agentSessionKey?: string): CronDelivery | n return delivery; } +function inferDeliveryFromContext(context?: DeliveryContext): CronDelivery | null { + const normalized = normalizeDeliveryContext(context); + if (!normalized?.to) { + return null; + } + const delivery: CronDelivery = { + mode: "announce", + to: normalized.to, + }; + if (normalized.channel) { + delivery.channel = normalized.channel as CronMessageChannel; + } + if (normalized.accountId) { + delivery.accountId = normalized.accountId; + } + if (normalized.threadId != null) { + delivery.threadId = normalized.threadId; + } + return delivery; +} + export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): AnyAgentTool { const callGateway = deps?.callGatewayTool ?? callGatewayTool; return { @@ -583,7 +609,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con } if ( - opts?.agentSessionKey && + (opts?.agentSessionKey || opts?.currentDeliveryContext) && job && typeof job === "object" && "payload" in job && @@ -613,7 +639,9 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con (mode === "" || mode === "announce") && !hasTarget; if (shouldInfer) { - const inferred = inferDeliveryFromSessionKey(opts.agentSessionKey); + const inferred = + inferDeliveryFromContext(opts.currentDeliveryContext) ?? + inferDeliveryFromSessionKey(opts.agentSessionKey); if (inferred) { (job as { delivery?: unknown }).delivery = { ...delivery,