fix: land slash command metadata parsing (#58725) (thanks @Mlightsnow)

This commit is contained in:
Peter Steinberger 2026-04-01 09:39:35 +01:00
parent 3b1f8e3461
commit f559ea126d
No known key found for this signature in database
5 changed files with 194 additions and 4 deletions

View File

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

View File

@ -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<string, unknown>): Record<string, unknown> {
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<string, unknown>) {
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<string, unknown>;
expect(payload.deliver).toBeUndefined();
expect(payload.channel).toBeUndefined();
expect(payload.to).toBeUndefined();
expect(payload.bestEffortDeliver).toBeUndefined();
const schedule = job.schedule as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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);
});
});

View File

@ -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();

View File

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

View File

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