From f559ea126d0a61e844ff94a2de99ecd11fc76fbf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 1 Apr 2026 09:39:35 +0100 Subject: [PATCH] fix: land slash command metadata parsing (#58725) (thanks @Mlightsnow) --- CHANGELOG.md | 1 + .../doctor-cron-store-migration.test.ts | 181 ++++++++++++++++++ src/cron/service.every-jobs-fire.test.ts | 4 +- src/cron/service/store.ts | 10 +- .../task-registry-import-boundary.test.ts | 2 + 5 files changed, 194 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85d3e5e902c..7e902218d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Exec/approvals: resume the original agent session after an approved async exec finishes, so external completion followups continue the task instead of waiting for a new user turn. (#58860) Thanks @Nanako0129. - Gateway/node pairing: create repair pairing requests when a paired node reconnects with allowlisted commands missing from its approved node record, refresh stale pending repair metadata, and surface paired node command metadata in `nodes status`/`describe` even while the node is offline. Fixes #58824. - Channels/plugins: keep bundled channel plugins loadable from legacy `channels.` config even under restrictive plugin allowlists, and make `openclaw doctor` warn only on real plugin blockers instead of misleading setup guidance. (#58873) Thanks @obviyus +- Auto-reply/commands: strip inbound metadata before slash command detection so wrapped `/model`, `/new`, and `/status` commands are recognized. (#58725) Thanks @Mlightsnow. ## 2026.3.31 diff --git a/src/commands/doctor-cron-store-migration.test.ts b/src/commands/doctor-cron-store-migration.test.ts index 00e7a7d6fc7..1449b89c129 100644 --- a/src/commands/doctor-cron-store-migration.test.ts +++ b/src/commands/doctor-cron-store-migration.test.ts @@ -1,6 +1,34 @@ import { describe, expect, it } from "vitest"; +import { DEFAULT_TOP_OF_HOUR_STAGGER_MS } from "../cron/stagger.js"; import { normalizeStoredCronJobs } from "./doctor-cron-store-migration.js"; +function makeLegacyJob(overrides: Record): Record { + return { + id: "job-legacy", + agentId: undefined, + name: "Legacy job", + description: null, + enabled: true, + deleteAfterRun: false, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_000_000, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { + kind: "systemEvent", + text: "tick", + }, + state: {}, + ...overrides, + }; +} + +function normalizeOneJob(job: Record) { + const jobs = [job]; + const result = normalizeStoredCronJobs(jobs); + return { job: jobs[0], result }; +} + describe("normalizeStoredCronJobs", () => { it("normalizes legacy cron fields and reports migration issues", () => { const jobs = [ @@ -132,4 +160,157 @@ describe("normalizeStoredCronJobs", () => { expect(jobs[0]?.payload).toMatchObject({ kind: "agentTurn", message: "ping" }); expect(jobs[1]?.payload).toMatchObject({ kind: "systemEvent", text: "pong" }); }); + + it("normalizes isolated legacy jobs without mutating runtime code paths", () => { + const { job, result } = normalizeOneJob( + makeLegacyJob({ + id: "job-1", + sessionKey: " agent:main:discord:channel:ops ", + schedule: { kind: "at", atMs: 1_700_000_000_000 }, + sessionTarget: "isolated", + payload: { + kind: "agentTurn", + message: "hi", + deliver: true, + channel: "telegram", + to: "7200373102", + bestEffortDeliver: true, + }, + isolation: { postToMainPrefix: "Cron" }, + }), + ); + + expect(result.mutated).toBe(true); + expect(job.sessionKey).toBe("agent:main:discord:channel:ops"); + expect(job.delivery).toEqual({ + mode: "announce", + channel: "telegram", + to: "7200373102", + bestEffort: true, + }); + expect("isolation" in job).toBe(false); + + const payload = job.payload as Record; + expect(payload.deliver).toBeUndefined(); + expect(payload.channel).toBeUndefined(); + expect(payload.to).toBeUndefined(); + expect(payload.bestEffortDeliver).toBeUndefined(); + + const schedule = job.schedule as Record; + expect(schedule.kind).toBe("at"); + expect(schedule.at).toBe(new Date(1_700_000_000_000).toISOString()); + expect(schedule.atMs).toBeUndefined(); + }); + + it("preserves stored custom session targets", () => { + const { job } = normalizeOneJob( + makeLegacyJob({ + id: "job-custom-session", + name: "Custom session", + schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" }, + sessionTarget: "session:ProjectAlpha", + payload: { + kind: "agentTurn", + message: "hello", + }, + }), + ); + + expect(job.sessionTarget).toBe("session:ProjectAlpha"); + expect(job.delivery).toEqual({ mode: "announce" }); + }); + + it("adds anchorMs to legacy every schedules", () => { + const createdAtMs = 1_700_000_000_000; + const { job } = normalizeOneJob( + makeLegacyJob({ + id: "job-every-legacy", + name: "Legacy every", + createdAtMs, + updatedAtMs: createdAtMs, + schedule: { kind: "every", everyMs: 120_000 }, + }), + ); + + const schedule = job.schedule as Record; + expect(schedule.kind).toBe("every"); + expect(schedule.anchorMs).toBe(createdAtMs); + }); + + it("adds default staggerMs to legacy recurring top-of-hour cron schedules", () => { + const { job } = normalizeOneJob( + makeLegacyJob({ + id: "job-cron-legacy", + name: "Legacy cron", + schedule: { kind: "cron", expr: "0 */2 * * *", tz: "UTC" }, + }), + ); + + const schedule = job.schedule as Record; + expect(schedule.kind).toBe("cron"); + expect(schedule.staggerMs).toBe(DEFAULT_TOP_OF_HOUR_STAGGER_MS); + }); + + it("adds default staggerMs to legacy 6-field top-of-hour cron schedules", () => { + const { job } = normalizeOneJob( + makeLegacyJob({ + id: "job-cron-seconds-legacy", + name: "Legacy cron seconds", + schedule: { kind: "cron", expr: "0 0 */3 * * *", tz: "UTC" }, + }), + ); + + const schedule = job.schedule as Record; + expect(schedule.kind).toBe("cron"); + expect(schedule.staggerMs).toBe(DEFAULT_TOP_OF_HOUR_STAGGER_MS); + }); + + it("removes invalid legacy staggerMs from non top-of-hour cron schedules", () => { + const { job } = normalizeOneJob( + makeLegacyJob({ + id: "job-cron-minute-legacy", + name: "Legacy minute cron", + schedule: { + kind: "cron", + expr: "17 * * * *", + tz: "UTC", + staggerMs: "bogus", + }, + }), + ); + + const schedule = job.schedule as Record; + expect(schedule.kind).toBe("cron"); + expect(schedule.staggerMs).toBeUndefined(); + }); + + it("migrates legacy string schedules and command-only payloads (#18445)", () => { + const { job, result } = normalizeOneJob({ + id: "imessage-refresh", + name: "iMessage Refresh", + enabled: true, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_000_000, + schedule: "0 */2 * * *", + command: "bash /tmp/imessage-refresh.sh", + timeout: 120, + state: {}, + }); + + expect(result.mutated).toBe(true); + expect(job.schedule).toEqual( + expect.objectContaining({ + kind: "cron", + expr: "0 */2 * * *", + }), + ); + expect(job.sessionTarget).toBe("main"); + expect(job.wakeMode).toBe("now"); + expect(job.payload).toEqual({ + kind: "systemEvent", + text: "bash /tmp/imessage-refresh.sh", + }); + expect("command" in job).toBe(false); + expect("timeout" in job).toBe(false); + }); }); diff --git a/src/cron/service.every-jobs-fire.test.ts b/src/cron/service.every-jobs-fire.test.ts index fa7b53e5986..0a6f878755a 100644 --- a/src/cron/service.every-jobs-fire.test.ts +++ b/src/cron/service.every-jobs-fire.test.ts @@ -182,9 +182,7 @@ describe("CronService interval/cron jobs fire on time", () => { const sfJob = jobs.find((job) => job.id === "legacy-every"); expect(sfJob?.state.lastStatus).toBe("ok"); expect(sfJob?.schedule.kind).toBe("every"); - if (sfJob?.schedule.kind === "every") { - expect(sfJob.schedule.anchorMs).toBe(nowMs); - } + expect(sfJob?.state.nextRunAtMs).toBe(nowMs + 8 * 60_000); cron.stop(); await store.cleanup(); diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index ac6992397ea..530dc0c40d6 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -32,9 +32,17 @@ export async function ensureLoaded( const fileMtimeMs = await getFileMtimeMs(state.deps.storePath); const loaded = await loadCronStore(state.deps.storePath); + const jobs = (loaded.jobs ?? []) as unknown as CronJob[]; + for (const job of jobs) { + // Persisted legacy jobs may predate the required `enabled` field. + // Keep runtime behavior backward-compatible without rewriting the store. + if (typeof job.enabled !== "boolean") { + job.enabled = true; + } + } state.store = { version: 1, - jobs: (loaded.jobs ?? []) as unknown as CronJob[], + jobs, }; state.storeLoadedAtMs = state.deps.nowMs(); state.storeFileMtimeMs = fileMtimeMs; diff --git a/src/tasks/task-registry-import-boundary.test.ts b/src/tasks/task-registry-import-boundary.test.ts index 4b4cc3ca3e4..0942a1653ea 100644 --- a/src/tasks/task-registry-import-boundary.test.ts +++ b/src/tasks/task-registry-import-boundary.test.ts @@ -6,6 +6,8 @@ const TASK_ROOT = path.resolve(import.meta.dirname); const SRC_ROOT = path.resolve(TASK_ROOT, ".."); const ALLOWED_IMPORTERS = new Set([ + "auto-reply/reply/commands-status.ts", + "auto-reply/reply/commands-tasks.ts", "tasks/runtime-internal.ts", "tasks/task-owner-access.ts", "tasks/task-status-access.ts",