diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index ac8e19791e7..3c09c5b1174 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -27,6 +27,7 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t ```bash openclaw cron list + openclaw cron get openclaw cron show ``` @@ -359,6 +360,9 @@ When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts ` # List all jobs openclaw cron list +# Get one stored job as JSON +openclaw cron get + # Show one job, including resolved delivery route openclaw cron show diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 0485205769e..5f52c928997 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -220,6 +220,7 @@ Manual run and inspection: ```bash openclaw cron list openclaw cron list --agent ops +openclaw cron get openclaw cron show openclaw cron run openclaw cron run --due @@ -228,6 +229,8 @@ openclaw cron runs --id --limit 50 `openclaw cron list` shows all matching jobs by default. Pass `--agent ` to show only jobs whose effective normalized agent id matches; jobs without a stored agent id count as the configured default agent. +`openclaw cron get ` returns the stored job JSON directly. Use `cron show ` when you want the human-readable view with delivery-route preview. + `cron list --json` and `cron show --json` include a top-level `status` field on each job, computed from `enabled`, `state.runningAtMs`, and `state.lastRunStatus`. Values: `disabled`, `running`, `ok`, `error`, `skipped`, or `idle`. This mirrors the human-readable status column so external tooling can read job state without re-deriving it. `cron runs` entries include delivery diagnostics with the intended cron target, the resolved target, message-tool sends, fallback use, and delivered state. diff --git a/docs/cli/index.md b/docs/cli/index.md index 14cf25a6156..57dc671a0f2 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -256,6 +256,7 @@ openclaw [--dev] [--profile ] cron status list + get add edit rm diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 24a8411d95a..896a1783cd9 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -467,7 +467,7 @@ enumeration of `src/gateway/server-methods/*.ts`. - - Automation: `wake` schedules an immediate or next-heartbeat wake text injection; `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` manage scheduled work. + - Automation: `wake` schedules an immediate or next-heartbeat wake text injection; `cron.get`, `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` manage scheduled work. - Skills and tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective`, `tools.invoke`. diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 2898b8ad90f..b134d7d7f7c 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -193,7 +193,7 @@ describe("cron tool", () => { action: "remove", jobId: "job-other", }), - ).rejects.toThrow("Cron tool is restricted to removing the current cron job."); + ).rejects.toThrow("Cron tool is restricted to the current cron job."); expect(callGatewayMock).not.toHaveBeenCalled(); }); @@ -233,7 +233,7 @@ describe("cron tool", () => { const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" }); await expect(tool.execute("call-runs-denied", args)).rejects.toThrow( - "Cron tool is restricted to removing the current cron job.", + "Cron tool is restricted to the current cron job.", ); expect(callGatewayMock).not.toHaveBeenCalled(); @@ -258,6 +258,33 @@ describe("cron tool", () => { expect(result.details).toEqual({ enabled: true }); }); + it("allows scoped isolated cron runs to get the current job", async () => { + callGatewayMock.mockResolvedValueOnce({ id: "job-current", name: "current" }); + const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" }); + + const result = await tool.execute("call-get", { + action: "get", + jobId: "job-current", + }); + + const params = expectSingleGatewayCallMethod("cron.get"); + expect(params).toStrictEqual({ id: "job-current" }); + expect(result.details).toEqual({ id: "job-current", name: "current" }); + }); + + it.each([ + ["another job", { action: "get", jobId: "job-other" }], + ["missing job id", { action: "get" }], + ])("denies scoped isolated cron runs from getting %s", async (_label, args) => { + const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" }); + + await expect(tool.execute("call-get-denied", args)).rejects.toThrow( + "Cron tool is restricted to the current cron job.", + ); + + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + it("allows scoped isolated cron runs to list only the current job", async () => { callGatewayMock.mockResolvedValueOnce({ jobs: [ @@ -366,7 +393,7 @@ describe("cron tool", () => { const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" }); await expect(tool.execute("call-denied", args)).rejects.toThrow( - "Cron tool is restricted to removing the current cron job.", + "Cron tool is restricted to the current cron job.", ); expect(callGatewayMock).not.toHaveBeenCalled(); @@ -437,6 +464,8 @@ describe("cron tool", () => { ["remove", { action: "remove", id: "job-2" }, { id: "job-2" }], ["run", { action: "run", jobId: "job-1" }, { id: "job-1", mode: "force" }], ["run", { action: "run", id: "job-2" }, { id: "job-2", mode: "force" }], + ["get", { action: "get", jobId: "job-1" }, { id: "job-1" }], + ["get", { action: "get", id: "job-2" }, { id: "job-2" }], ["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }], ["runs", { action: "runs", id: "job-2" }, { id: "job-2" }], ])("%s sends id to gateway", async (action, args, expectedParams) => { diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index b531b593ce3..726b83f1574 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -29,7 +29,17 @@ import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-h // We spell out job/patch properties so that LLMs know what fields to send. // Nested unions are avoided; runtime validation happens in normalizeCronJob*. -const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs", "wake"] as const; +const CRON_ACTIONS = [ + "status", + "list", + "get", + "add", + "update", + "remove", + "run", + "runs", + "wake", +] as const; const CRON_SCHEDULE_KINDS = ["at", "every", "cron"] as const; const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const; @@ -315,7 +325,6 @@ export const CronToolSchema = Type.Object( type CronToolOptions = { agentSessionKey?: string; currentDeliveryContext?: DeliveryContext; - /** Restrict this cron tool instance to removing only this active cron job. */ selfRemoveOnlyJobId?: string; }; @@ -350,7 +359,7 @@ function readCronJobIdParam(params: Record) { return readStringParam(params, "jobId") ?? readStringParam(params, "id"); } -const CRON_SELF_REMOVE_SCOPE_ERROR = "Cron tool is restricted to removing the current cron job."; +const CRON_SELF_REMOVE_SCOPE_ERROR = "Cron tool is restricted to the current cron job."; function readCronSelfRemoveOnlyJobId(opts: CronToolOptions | undefined) { return opts?.selfRemoveOnlyJobId?.trim() || undefined; @@ -369,7 +378,7 @@ function assertCronSelfRemoveScope( if (!selfRemoveOnlyJobId || isCronSelfIntrospectionAction(action)) { return; } - if (action === "remove" || action === "runs") { + if (action === "get" || action === "remove" || action === "runs") { const id = readCronJobIdParam(params); if (id && id === selfRemoveOnlyJobId) { return; @@ -623,13 +632,14 @@ export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): Any name: "cron", ownerOnly: isOpenClawOwnerOnlyCoreToolName("cron"), displaySummary: CRON_TOOL_DISPLAY_SUMMARY, - description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, "check back later" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling. + description: `Manage Gateway cron jobs (status/list/get/add/update/remove/run/runs) and send wake events. Use this for reminders, "check back later" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling. Main-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in \`openclaw tasks\`. ACTIONS: - status: Check cron scheduler status - list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session) +- get: Get one job by id (requires jobId) - add: Create job (requires job object, see schema below) - update: Modify job (requires jobId + patch object) - remove: Delete job (requires jobId) @@ -692,7 +702,7 @@ CRITICAL CONSTRAINTS: Default: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding. RESTRICTED CRON RUNS: -- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, runs (job run history) is allowed for the current job only, and mutation actions remain limited to removing the current cron job. +- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, get/runs are allowed for the current job only, and mutation actions remain limited to removing the current cron job. WAKE MODES (for wake action): - "next-heartbeat" (default): Wake on next heartbeat @@ -756,6 +766,13 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con selfRemoveOnlyJobId ? filterCronListResultToJobId(result, selfRemoveOnlyJobId) : result, ); } + case "get": { + const id = readCronJobIdParam(params); + if (!id) { + throw new Error("jobId required (id accepted for backward compatibility)"); + } + return jsonResult(await callGateway("cron.get", gatewayOpts, { id })); + } case "add": { // Flat-params recovery: non-frontier models (e.g. Grok) sometimes flatten // job properties to the top level alongside `action` instead of nesting diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index dd50ce7d031..7018a26b193 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -513,6 +513,14 @@ describe("cron cli", () => { expect(listCall?.[2]).toEqual({ includeDisabled: false, agentId: "ops" }); }); + it("routes cron get to cron.get with the provided id", async () => { + await runCronCommand(["cron", "get", "job-1"]); + + const getCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.get"); + expect(getCall?.[2]).toEqual({ id: "job-1" }); + expect(stdoutText()).toContain('"id": "job-1"'); + }); + it("paginates cron show lookups", async () => { resetGatewayMock(); callGatewayFromCli.mockImplementation( diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts index 0c1dbe59a62..de1fe554ccb 100644 --- a/src/cli/cron-cli/register.cron-simple.ts +++ b/src/cli/cron-cli/register.cron-simple.ts @@ -110,6 +110,21 @@ export function registerCronSimpleCommands(cron: Command) { enabled: false, }); + addGatewayClientOptions( + cron + .command("get") + .description("Get a cron job as JSON") + .argument("", "Job id") + .action(async (id, opts) => { + try { + const res = await callGatewayFromCli("cron.get", opts, { id: String(id) }); + printCronJson(res); + } catch (err) { + handleCronCliError(err); + } + }), + ); + addGatewayClientOptions( cron .command("show") diff --git a/src/cron/service-contract.ts b/src/cron/service-contract.ts index 3ae1246cb87..3d579f73f50 100644 --- a/src/cron/service-contract.ts +++ b/src/cron/service-contract.ts @@ -29,6 +29,7 @@ export interface CronServiceContract { run(id: string, mode?: CronRunMode): Promise; enqueueRun(id: string, mode?: CronRunMode): Promise; getJob(id: string): CronJob | undefined; + readJob(id: string): Promise; getDefaultAgentId(): string | undefined; wake(opts: { mode: CronWakeMode; text: string; sessionKey?: string }): CronWakeResult; } diff --git a/src/cron/service.get-job.test.ts b/src/cron/service.get-job.test.ts index 93fb0d3a561..a238f84dc95 100644 --- a/src/cron/service.get-job.test.ts +++ b/src/cron/service.get-job.test.ts @@ -10,10 +10,10 @@ const logger = createNoopLogger(); const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-get-job-" }); installCronTestHooks({ logger }); -function createCronService(storePath: string) { +function createCronService(storePath: string, cronEnabled = true) { return new CronService({ storePath, - cronEnabled: true, + cronEnabled, log: logger, enqueueSystemEvent: vi.fn(), requestHeartbeat: vi.fn(), @@ -38,7 +38,8 @@ describe("CronService.getJob", () => { }); expect(cron.getJob(added.id)?.id).toBe(added.id); - expect(cron.getJob("missing-job-id")).toBeUndefined(); + await expect(cron.readJob(added.id)).resolves.toEqual(added); + await expect(cron.readJob("missing-job-id")).resolves.toBeUndefined(); } finally { cron.stop(); } @@ -59,6 +60,12 @@ describe("CronService.getJob", () => { payload: { kind: "systemEvent", text: "ping" }, delivery: { mode: "webhook", to: "https://example.invalid/cron" }, }); + await expect(cron.readJob(webhookJob.id)).resolves.toMatchObject({ + delivery: { + mode: "webhook", + to: "https://example.invalid/cron", + }, + }); expect(cron.getJob(webhookJob.id)?.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/cron", @@ -67,4 +74,27 @@ describe("CronService.getJob", () => { cron.stop(); } }); + + it("loads persisted jobs for direct reads without starting the scheduler", async () => { + const { storePath } = await makeStorePath(); + const writer = createCronService(storePath); + await writer.start(); + const persisted = await writer.add({ + name: "persisted-job", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + writer.stop(); + + const reader = createCronService(storePath, false); + + await expect(reader.readJob(persisted.id)).resolves.toMatchObject({ + id: persisted.id, + name: "persisted-job", + }); + expect(reader.getJob(persisted.id)).toBeDefined(); + }); }); diff --git a/src/cron/service.ts b/src/cron/service.ts index 6770e8264e1..539ab81426c 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -64,6 +64,10 @@ export class CronService implements CronServiceContract { return this.state.store?.jobs.find((job) => job.id === id); } + async readJob(id: string): Promise { + return await ops.readJob(this.state, id); + } + getDefaultAgentId(): string | undefined { return this.state.deps.defaultAgentId; } diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 6002a271f1a..9394ddc9c09 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -236,6 +236,13 @@ export async function list(state: CronServiceState, opts?: { includeDisabled?: b }); } +export async function readJob(state: CronServiceState, id: string) { + return await locked(state, async () => { + await ensureLoadedForRead(state); + return state.store?.jobs.find((job) => job.id === id); + }); +} + function resolveEnabledFilter(opts?: CronListPageOptions): CronJobsEnabledFilter { if (opts?.enabled === "all" || opts?.enabled === "enabled" || opts?.enabled === "disabled") { return opts.enabled; diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index f942973d459..0de03db4838 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -113,6 +113,7 @@ const METHOD_SCOPE_GROUPS: Record = { "sessions.usage", "sessions.usage.timeseries", "sessions.usage.logs", + "cron.get", "cron.list", "cron.status", "cron.runs", diff --git a/src/gateway/protocol/cron-validators.test.ts b/src/gateway/protocol/cron-validators.test.ts index 69073b93ef4..e7eb2b4947b 100644 --- a/src/gateway/protocol/cron-validators.test.ts +++ b/src/gateway/protocol/cron-validators.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { validateCronAddParams, + validateCronGetParams, validateCronListParams, validateCronRemoveParams, validateCronRunParams, @@ -54,6 +55,13 @@ describe("cron protocol validators", () => { expect(validateCronUpdateParams({ jobId: "job-2", patch: { enabled: true } })).toBe(true); }); + it("accepts get params for id and jobId selectors", () => { + expect(validateCronGetParams({ id: "job-1" })).toBe(true); + expect(validateCronGetParams({ jobId: "job-2" })).toBe(true); + expect(validateCronGetParams({})).toBe(false); + expect(validateCronGetParams({ id: "" })).toBe(false); + }); + it("accepts delivery threadId on add and update params", () => { expect( validateCronAddParams({ diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 46f8dc17d14..ce6d23a9daa 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -146,6 +146,8 @@ import { ConnectParamsSchema, type CronAddParams, CronAddParamsSchema, + type CronGetParams, + CronGetParamsSchema, type CronJob, CronJobSchema, type CronListParams, @@ -671,6 +673,7 @@ export const validateSkillsSearchParams = ajv.compile(Skills export const validateSkillsDetailParams = ajv.compile(SkillsDetailParamsSchema); export const validateCronListParams = ajv.compile(CronListParamsSchema); export const validateCronStatusParams = ajv.compile(CronStatusParamsSchema); +export const validateCronGetParams = ajv.compile(CronGetParamsSchema); export const validateCronAddParams = ajv.compile(CronAddParamsSchema); export const validateCronUpdateParams = ajv.compile(CronUpdateParamsSchema); export const validateCronRemoveParams = ajv.compile(CronRemoveParamsSchema); @@ -940,6 +943,7 @@ export { CronJobSchema, CronListParamsSchema, CronStatusParamsSchema, + CronGetParamsSchema, CronAddParamsSchema, CronUpdateParamsSchema, CronRemoveParamsSchema, @@ -1122,6 +1126,7 @@ export type { CronJob, CronListParams, CronStatusParams, + CronGetParams, CronAddParams, CronUpdateParams, CronRemoveParams, diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index b3ffabcae46..deff5cc5b84 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -351,6 +351,8 @@ export const CronListParamsSchema = Type.Object( export const CronStatusParamsSchema = Type.Object({}, { additionalProperties: false }); +export const CronGetParamsSchema = cronIdOrJobIdParams({}); + export const CronAddParamsSchema = Type.Object( { name: NonEmptyString, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 18034b94ba4..3a22869362b 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -116,6 +116,7 @@ import { } from "./config.js"; import { CronAddParamsSchema, + CronGetParamsSchema, CronJobSchema, CronListParamsSchema, CronRemoveParamsSchema, @@ -454,6 +455,7 @@ export const ProtocolSchemas = { CronJob: CronJobSchema, CronListParams: CronListParamsSchema, CronStatusParams: CronStatusParamsSchema, + CronGetParams: CronGetParamsSchema, CronAddParams: CronAddParamsSchema, CronUpdateParams: CronUpdateParamsSchema, CronRemoveParams: CronRemoveParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 6b149c19721..0f5df850f13 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -190,6 +190,7 @@ export type SkillsUpdateParams = SchemaType<"SkillsUpdateParams">; export type CronJob = SchemaType<"CronJob">; export type CronListParams = SchemaType<"CronListParams">; export type CronStatusParams = SchemaType<"CronStatusParams">; +export type CronGetParams = SchemaType<"CronGetParams">; export type CronAddParams = SchemaType<"CronAddParams">; export type CronUpdateParams = SchemaType<"CronUpdateParams">; export type CronRemoveParams = SchemaType<"CronRemoveParams">; diff --git a/src/gateway/server-cron-lazy.test.ts b/src/gateway/server-cron-lazy.test.ts index b86bced8da5..74ec16ff6d8 100644 --- a/src/gateway/server-cron-lazy.test.ts +++ b/src/gateway/server-cron-lazy.test.ts @@ -43,6 +43,17 @@ describe("createLazyGatewayCronState", () => { expect(cron.status).toHaveBeenCalledTimes(1); }); + it("loads the cron service for direct job reads", async () => { + const cron = createCronService(); + hoisted.setState(createCronState(cron)); + + const lazy = createLazyGatewayCronState(createParams()); + await lazy.cron.readJob("demo"); + + expect(hoisted.buildGatewayCronService).toHaveBeenCalledTimes(1); + expect(cron.readJob).toHaveBeenCalledWith("demo"); + }); + it("starts the loaded cron service once", async () => { const cron = createCronService(); hoisted.setState(createCronState(cron)); @@ -140,6 +151,7 @@ function createCronService(): CronServiceContract { run: vi.fn(async () => ({ ok: true, ran: false, reason: "invalid-spec" }) as never), enqueueRun: vi.fn(async () => ({ ok: true, ran: false, reason: "invalid-spec" }) as never), getJob: vi.fn(() => undefined), + readJob: vi.fn(async () => undefined), getDefaultAgentId: vi.fn(() => "default"), wake: vi.fn(() => ({ ok: true })), }; diff --git a/src/gateway/server-cron-lazy.ts b/src/gateway/server-cron-lazy.ts index 08c3808c4ad..e3775a4c96e 100644 --- a/src/gateway/server-cron-lazy.ts +++ b/src/gateway/server-cron-lazy.ts @@ -102,6 +102,9 @@ export function createLazyGatewayCronState(params: LazyGatewayCronParams): Gatew } return loaded.state.cron.getJob(id); }, + async readJob(id) { + return await (await load()).state.cron.readJob(id); + }, getDefaultAgentId() { if (!loaded) { return undefined; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 18ae9426b16..450ffb369f4 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -156,6 +156,7 @@ const BASE_METHODS = [ "node.pending.ack", "node.invoke.result", "node.event", + "cron.get", "cron.list", "cron.status", "cron.add", diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 1ae45be2f00..b1cf2341a20 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -23,6 +23,7 @@ import { errorShape, formatValidationErrors, validateCronAddParams, + validateCronGetParams, validateCronListParams, validateCronRemoveParams, validateCronRunParams, @@ -248,6 +249,39 @@ export const cronHandlers: GatewayRequestHandlers = { const status = await context.cron.status(); respond(true, status, undefined); }, + "cron.get": async ({ params, respond, context }) => { + if (!validateCronGetParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid cron.get params: ${formatValidationErrors(validateCronGetParams.errors)}`, + ), + ); + return; + } + const p = params as { id?: string; jobId?: string }; + const jobId = p.id ?? p.jobId; + if (!jobId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.get params: missing id"), + ); + return; + } + const job = await context.cron.readJob(jobId); + if (!job) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `cron job not found: ${jobId}`), + ); + return; + } + respond(true, job, undefined); + }, "cron.add": async ({ params, respond, context }) => { const sessionKey = typeof (params as { sessionKey?: unknown } | null)?.sessionKey === "string" diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index 01b965404d7..a4f83c43097 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -78,6 +78,7 @@ function createCronContext(currentJob?: CronJob) { getDefaultAgentId: vi.fn(() => "main"), getJob: vi.fn(() => currentJob), wake: vi.fn(() => ({ ok: true }) as const), + readJob: vi.fn(async (id: string) => (id === currentJob?.id ? currentJob : undefined)), }, logGateway: { info: vi.fn(), @@ -100,6 +101,20 @@ async function invokeCronAdd(params: Record) { return { context, respond }; } +async function invokeCronGet(params: Record, currentJob?: CronJob) { + const context = createCronContext(currentJob); + const respond = vi.fn(); + await cronHandlers["cron.get"]({ + req: {} as never, + params: params as never, + respond: respond as never, + context: context as never, + client: null, + isWebchatConnect: () => false, + }); + return { context, respond }; +} + async function invokeCronUpdate(params: Record, currentJob: CronJob) { const context = createCronContext(currentJob); const respond = vi.fn(); @@ -231,6 +246,24 @@ describe("cron method validation", () => { expect(respond).toHaveBeenCalledWith(true, { id: "cron-1" }, undefined); }); + it("returns a single cron job for cron.get", async () => { + const job = createCronJob({ id: "cron-42", name: "single job" }); + + const { context, respond } = await invokeCronGet({ id: "cron-42" }, job); + + expect(context.cron.readJob).toHaveBeenCalledWith("cron-42"); + expect(respond).toHaveBeenCalledWith(true, job, undefined); + }); + + it("returns INVALID_REQUEST when cron.get cannot find the job", async () => { + const { respond } = await invokeCronGet({ jobId: "missing" }); + + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "cron job not found: missing", + }); + }); + it("accepts threadId on announce delivery update params", async () => { getRuntimeConfig.mockReturnValue({ channels: { diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 744f6f0a1b8..4ee65cf6a41 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -469,6 +469,16 @@ describe("gateway server cron", () => { detail: "webhook", }); + const getRes = await directCronReq(cronState, "cron.get", { id: String(dailyJobId) }); + expect(getRes.ok).toBe(true); + expect((getRes.payload as { id?: unknown } | null)?.id).toBe(dailyJobId); + expect((getRes.payload as { name?: unknown } | null)?.name).toBe("daily"); + + const missingGetRes = await directCronReq(cronState, "cron.get", { id: "missing-job-id" }); + expect(missingGetRes.ok).toBe(false); + expect(missingGetRes.error?.code).toBe("INVALID_REQUEST"); + expect(missingGetRes.error?.message).toContain("cron job not found: missing-job-id"); + const routeAtMs = Date.now() - 1; const routeRes = await directCronReq(cronState, "cron.add", { name: "route test", diff --git a/src/plugins/contracts/scheduled-turns.contract.test.ts b/src/plugins/contracts/scheduled-turns.contract.test.ts index 0f42f0c2f4f..5adca5ac6ff 100644 --- a/src/plugins/contracts/scheduled-turns.contract.test.ts +++ b/src/plugins/contracts/scheduled-turns.contract.test.ts @@ -101,6 +101,7 @@ function createMockCronService(): CronServiceContract { run: vi.fn(async () => ({ ok: true, ran: false, reason: "not-due" })), enqueueRun: vi.fn(async () => ({ ok: true, ran: false, reason: "not-due" })), getJob: vi.fn(() => undefined), + readJob: vi.fn(async () => undefined), getDefaultAgentId: vi.fn(() => undefined), wake: vi.fn(() => ({ ok: true })), } as CronServiceContract; diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json index 1a4d01761f9..a12a8c35724 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json @@ -137,12 +137,12 @@ }, { "deferLoading": true, - "description": "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, runs (job run history) is allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.", + "description": "Manage Gateway cron jobs (status/list/get/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- get: Get one job by id (requires jobId)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, get/runs are allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.", "inputSchema": { "additionalProperties": true, "properties": { "action": { - "enum": ["status", "list", "add", "update", "remove", "run", "runs", "wake"], + "enum": ["status", "list", "get", "add", "update", "remove", "run", "runs", "wake"], "type": "string" }, "agentId": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json index fcbb76caccb..5e98adbed56 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json @@ -137,12 +137,12 @@ }, { "deferLoading": true, - "description": "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, runs (job run history) is allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.", + "description": "Manage Gateway cron jobs (status/list/get/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- get: Get one job by id (requires jobId)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, get/runs are allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.", "inputSchema": { "additionalProperties": true, "properties": { "action": { - "enum": ["status", "list", "add", "update", "remove", "run", "runs", "wake"], + "enum": ["status", "list", "get", "add", "update", "remove", "run", "runs", "wake"], "type": "string" }, "agentId": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index ce1eb91fe5b..4310d0b21d2 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -137,12 +137,12 @@ }, { "deferLoading": true, - "description": "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, runs (job run history) is allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.", + "description": "Manage Gateway cron jobs (status/list/get/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- get: Get one job by id (requires jobId)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, get/runs are allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.", "inputSchema": { "additionalProperties": true, "properties": { "action": { - "enum": ["status", "list", "add", "update", "remove", "run", "runs", "wake"], + "enum": ["status", "list", "get", "add", "update", "remove", "run", "runs", "wake"], "type": "string" }, "agentId": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index 5555c0e4832..8384a201977 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -217,8 +217,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 140 }, "dynamicToolsJson": { - "chars": 43000, - "roughTokens": 10750 + "chars": 43053, + "roughTokens": 10764 }, "openClawDeveloperInstructions": { "chars": 5436, @@ -229,8 +229,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 7129 }, "totalWithDynamicToolsJson": { - "chars": 71518, - "roughTokens": 17880 + "chars": 71571, + "roughTokens": 17893 }, "userInputText": { "chars": 870, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index 121efcb1d4a..ea82ffb4797 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -217,8 +217,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 140 }, "dynamicToolsJson": { - "chars": 42691, - "roughTokens": 10673 + "chars": 42744, + "roughTokens": 10686 }, "openClawDeveloperInstructions": { "chars": 4412, @@ -229,8 +229,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6748 }, "totalWithDynamicToolsJson": { - "chars": 69685, - "roughTokens": 17422 + "chars": 69738, + "roughTokens": 17435 }, "userInputText": { "chars": 370, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index 56824b85fd0..99a23a977e1 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -218,8 +218,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 140 }, "dynamicToolsJson": { - "chars": 43869, - "roughTokens": 10968 + "chars": 43922, + "roughTokens": 10981 }, "openClawDeveloperInstructions": { "chars": 4412, @@ -230,8 +230,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 7155 }, "totalWithDynamicToolsJson": { - "chars": 72490, - "roughTokens": 18123 + "chars": 72543, + "roughTokens": 18136 }, "userInputText": { "chars": 608,