fix(cron): preserve current delivery target context

This commit is contained in:
Peter Steinberger
2026-04-26 02:10:35 +01:00
parent 0731fc1942
commit e309fd485e
5 changed files with 193 additions and 5 deletions

View File

@@ -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.

View File

@@ -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] : []),

View File

@@ -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",
},
});
});
});

View File

@@ -60,10 +60,16 @@ describe("cron tool", () => {
async function executeAddAndReadDelivery(params: {
callId: string;
agentSessionKey: string;
agentSessionKey?: string;
currentDeliveryContext?: NonNullable<
Parameters<typeof createCronTool>[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({

View File

@@ -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,