Download src/agents/tools/cron-tool.flat-params.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 18 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/tools/cron-tool.flat-params.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/agents/tools/cron-tool.flat-params.test.ts
-
curl -L -o cron-tool.flat-params.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/tools/cron-tool.flat-params.test.ts
18 kB
| // Cron flat-parameter tests cover model-friendly shorthand recovery before | |
| // gateway cron RPC dispatch. | |
| import { beforeEach, describe, expect, it, vi } from "vitest"; | |
| const { callGatewayToolMock } = vi.hoisted(() => ({ | |
| callGatewayToolMock: vi.fn(), | |
| })); | |
| vi.mock("../agent-scope.js", async () => { | |
| const actual = await vi.importActual<typeof import("../agent-scope.js")>("../agent-scope.js"); | |
| return { | |
| ...actual, | |
| resolveSessionAgentId: actual.resolveSessionAgentId, | |
| }; | |
| }); | |
| import { getToolTerminalPresentation } from "../tool-terminal-presentation.js"; | |
| import { createCronTool } from "./cron-tool.js"; | |
| describe("cron tool flat-params", () => { | |
| beforeEach(() => { | |
| callGatewayToolMock.mockClear(); | |
| callGatewayToolMock.mockResolvedValue({ ok: true }); | |
| }); | |
| function firstGatewayToolCall<TParams>(): [string, unknown, TParams] { | |
| return gatewayToolCall<TParams>(0); | |
| } | |
| function gatewayToolCall<TParams>(index: number): [string, unknown, TParams] { | |
| const call = callGatewayToolMock.mock.calls[index]; | |
| if (!call) { | |
| throw new Error(`expected callGatewayTool call ${index + 1}`); | |
| } | |
| return call as [string, unknown, TParams]; | |
| } | |
| it("presents read-only cron metadata without job content", () => { | |
| const tool = createCronTool(); | |
| const terminalPresentation = getToolTerminalPresentation(tool); | |
| if (!terminalPresentation) { | |
| throw new Error("expected cron terminal presentation"); | |
| } | |
| expect( | |
| terminalPresentation( | |
| { action: "list" }, | |
| { | |
| content: [], | |
| details: { | |
| total: 2, | |
| jobs: [ | |
| { id: "one", name: "private reminder", payload: { text: "secret" } }, | |
| { id: "two", name: "another reminder" }, | |
| ], | |
| }, | |
| }, | |
| ), | |
| ).toEqual({ text: "Automations listed.\nCount: 2" }); | |
| expect( | |
| terminalPresentation( | |
| { action: "list" }, | |
| { | |
| content: [], | |
| details: { | |
| total: 250, | |
| jobs: [{ id: "one" }, { id: "two" }], | |
| }, | |
| }, | |
| ), | |
| ).toEqual({ text: "Automations listed.\nCount: 250" }); | |
| expect( | |
| terminalPresentation( | |
| { action: "add" }, | |
| { content: [], details: { id: "three", name: "private reminder" } }, | |
| ), | |
| ).toBeUndefined(); | |
| }); | |
| it("binds recovered agentTurn jobs to the creating conversation by default", async () => { | |
| const tool = createCronTool( | |
| { agentSessionKey: "agent:main:discord:channel:ops" }, | |
| { callGatewayTool: callGatewayToolMock }, | |
| ); | |
| await tool.execute("call-flat-session-key", { | |
| action: "add", | |
| sessionKey: "agent:main:telegram:group:-100123:topic:99", | |
| schedule: { kind: "at", at: new Date(123).toISOString() }, | |
| message: "do stuff", | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<{ | |
| sessionKey?: string; | |
| sessionTarget?: string; | |
| }>(); | |
| expect(method).toBe("cron.add"); | |
| expect(params.sessionTarget).toBe("current"); | |
| expect(params.sessionKey).toBe("agent:main:discord:channel:ops"); | |
| }); | |
| it("recovers flat cron schedule shorthand for add", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-flat-cron-add", { | |
| action: "add", | |
| name: "hourly report", | |
| cron: "0 * * * *", | |
| tz: "UTC", | |
| staggerMs: 5000, | |
| message: "send report", | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<{ | |
| schedule?: unknown; | |
| payload?: unknown; | |
| }>(); | |
| expect(method).toBe("cron.add"); | |
| expect(params.schedule).toEqual({ | |
| kind: "cron", | |
| expr: "0 * * * *", | |
| tz: "UTC", | |
| staggerMs: 5000, | |
| }); | |
| expect(params.payload).toEqual({ | |
| kind: "agentTurn", | |
| message: "send report", | |
| }); | |
| }); | |
| it("recovers flat script payload fields before agent-turn hints", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-flat-script-add", { | |
| action: "add", | |
| name: "queue watcher", | |
| everyMs: 60_000, | |
| script: "return { notify: 'changed' }", | |
| timeoutSeconds: 30, | |
| toolBudget: 12, | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<{ | |
| payload?: unknown; | |
| }>(); | |
| expect(method).toBe("cron.add"); | |
| expect(params.payload).toEqual({ | |
| kind: "script", | |
| script: "return { notify: 'changed' }", | |
| timeoutSeconds: 30, | |
| toolBudget: 12, | |
| }); | |
| }); | |
| it("recovers a flat trigger when adding a job", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-flat-trigger-add", { | |
| action: "add", | |
| name: "watcher", | |
| schedule: { kind: "every", everyMs: 60_000 }, | |
| message: "report the change", | |
| trigger: { script: "json({ fire: false })", once: true }, | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<{ | |
| trigger?: { script?: string; once?: boolean }; | |
| }>(); | |
| expect(method).toBe("cron.add"); | |
| expect(params.trigger).toEqual({ script: "json({ fire: false })", once: true }); | |
| }); | |
| it("rejects flat on-exit schedule shorthand for add", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await expect( | |
| tool.execute("call-flat-onexit-add", { | |
| action: "add", | |
| name: "rebuild on exit", | |
| kind: "on-exit", | |
| command: "pnpm build", | |
| cwd: "/repo", | |
| message: "rebuilt", | |
| }), | |
| ).rejects.toThrow("automation on-exit schedules cannot be created or edited"); | |
| expect(callGatewayToolMock).not.toHaveBeenCalled(); | |
| }); | |
| it("rejects flat command schedule shorthand for add", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await expect( | |
| tool.execute("call-flat-onexit-infer", { | |
| action: "add", | |
| name: "watch build", | |
| command: "make", | |
| message: "done", | |
| }), | |
| ).rejects.toThrow("automation on-exit schedules cannot be created or edited"); | |
| expect(callGatewayToolMock).not.toHaveBeenCalled(); | |
| }); | |
| it("rejects flat on-exit schedule shorthand for update", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await expect( | |
| tool.execute("call-flat-onexit-update", { | |
| action: "update", | |
| jobId: "job-onexit", | |
| kind: "on-exit", | |
| command: "pnpm build", | |
| cwd: "/repo", | |
| }), | |
| ).rejects.toThrow("automation on-exit schedules cannot be created or edited"); | |
| expect(callGatewayToolMock).not.toHaveBeenCalled(); | |
| }); | |
| it("rejects flat command schedule shorthand for update", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await expect( | |
| tool.execute("call-flat-onexit-update-infer", { | |
| action: "update", | |
| jobId: "job-infer", | |
| command: "make", | |
| }), | |
| ).rejects.toThrow("automation on-exit schedules cannot be created or edited"); | |
| expect(callGatewayToolMock).not.toHaveBeenCalled(); | |
| }); | |
| it("passes local cron wall-clock expression and timezone through add", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-local-cron-add", { | |
| action: "add", | |
| name: "shanghai reminder", | |
| cron: "0 18 * * *", | |
| tz: "Asia/Shanghai", | |
| message: "send reminder", | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<{ | |
| schedule?: unknown; | |
| }>(); | |
| expect(method).toBe("cron.add"); | |
| expect(params.schedule).toEqual({ | |
| kind: "cron", | |
| expr: "0 18 * * *", | |
| tz: "Asia/Shanghai", | |
| }); | |
| }); | |
| it("leaves out-of-range flat atMs for gateway validation", async () => { | |
| // The gateway owns final schedule validation; flat recovery should preserve | |
| // the supplied value instead of silently coercing an invalid date. | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| const invalidAtMs = 8_640_000_000_000_001; | |
| await tool.execute("call-flat-invalid-atms-add", { | |
| action: "add", | |
| name: "bad date", | |
| atMs: invalidAtMs, | |
| message: "send reminder", | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<{ | |
| schedule?: { at?: unknown; kind?: unknown }; | |
| }>(); | |
| expect(method).toBe("cron.add"); | |
| expect(params.schedule).toEqual({ kind: "at", at: invalidAtMs }); | |
| }); | |
| it("recovers flat cron schedule shorthand for update", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-flat-cron-update", { | |
| action: "update", | |
| jobId: "job-123", | |
| cron: "15 8 * * 1-5", | |
| tz: "America/Los_Angeles", | |
| staggerMs: 30_000, | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<{ | |
| id?: string; | |
| patch?: { schedule?: unknown }; | |
| }>(); | |
| expect(method).toBe("cron.update"); | |
| expect(params.id).toBe("job-123"); | |
| expect(params.patch?.schedule).toEqual({ | |
| kind: "cron", | |
| expr: "15 8 * * 1-5", | |
| tz: "America/Los_Angeles", | |
| staggerMs: 30_000, | |
| }); | |
| }); | |
| it("recovers flat script payload fields for update", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-flat-script-update", { | |
| action: "update", | |
| jobId: "job-script", | |
| script: "return { wake: 'now' }", | |
| timeoutSeconds: 45, | |
| toolBudget: 8, | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<{ | |
| id?: string; | |
| patch?: { payload?: unknown }; | |
| }>(); | |
| expect(method).toBe("cron.update"); | |
| expect(params).toEqual({ | |
| id: "job-script", | |
| patch: { | |
| payload: { | |
| kind: "script", | |
| script: "return { wake: 'now' }", | |
| timeoutSeconds: 45, | |
| toolBudget: 8, | |
| }, | |
| }, | |
| }); | |
| }); | |
| it("recovers a flat trigger when updating a job", async () => { | |
| callGatewayToolMock | |
| .mockResolvedValueOnce({ | |
| id: "job-trigger", | |
| configRevision: "sha256:flat-trigger-update", | |
| trigger: null, | |
| payload: { kind: "systemEvent", text: "before" }, | |
| }) | |
| .mockResolvedValueOnce({ ok: true }); | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-flat-trigger-update", { | |
| action: "update", | |
| jobId: "job-trigger", | |
| trigger: { script: "json({ fire: true })", once: false }, | |
| }); | |
| const [getMethod, _getGatewayOpts, getParams] = firstGatewayToolCall<{ id?: string }>(); | |
| expect(getMethod).toBe("cron.get"); | |
| expect(getParams).toEqual({ id: "job-trigger" }); | |
| const [method, _gatewayOpts, params] = gatewayToolCall<{ | |
| id?: string; | |
| expectedConfigRevision?: string; | |
| patch?: { trigger?: { script?: string; once?: boolean } }; | |
| }>(1); | |
| expect(method).toBe("cron.update"); | |
| expect(params).toEqual({ | |
| id: "job-trigger", | |
| expectedConfigRevision: "sha256:flat-trigger-update", | |
| patch: { trigger: { script: "json({ fire: true })", once: false } }, | |
| }); | |
| }); | |
| it("recovers a flat trigger clear when updating a job", async () => { | |
| callGatewayToolMock | |
| .mockResolvedValueOnce({ | |
| id: "job-trigger", | |
| configRevision: "sha256:flat-trigger-clear", | |
| trigger: { script: "json({ fire: false })", once: true }, | |
| payload: { kind: "systemEvent", text: "before" }, | |
| }) | |
| .mockResolvedValueOnce({ ok: true }); | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-flat-trigger-clear", { | |
| action: "update", | |
| jobId: "job-trigger", | |
| trigger: null, | |
| }); | |
| const [getMethod, _getGatewayOpts, getParams] = firstGatewayToolCall<{ id?: string }>(); | |
| expect(getMethod).toBe("cron.get"); | |
| expect(getParams).toEqual({ id: "job-trigger" }); | |
| const [method, _gatewayOpts, params] = gatewayToolCall<{ | |
| id?: string; | |
| expectedConfigRevision?: string; | |
| patch?: { trigger?: null }; | |
| }>(1); | |
| expect(method).toBe("cron.update"); | |
| expect(params).toEqual({ | |
| id: "job-trigger", | |
| expectedConfigRevision: "sha256:flat-trigger-clear", | |
| patch: { trigger: null }, | |
| }); | |
| }); | |
| it("trims trailing whitespace from recognized job object keys (#95407)", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-trailing-space", { | |
| action: "add", | |
| job: { | |
| name: "Holiday Check-in", | |
| description: "Casual check-in", | |
| "schedule ": { kind: "cron", expr: "30 10,20 * * *", tz: "Europe/Madrid" }, | |
| "sessionTarget ": "isolated", | |
| "payload ": { kind: "agentTurn", message: "How's it going?" }, | |
| "enabled ": true, | |
| }, | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<{ | |
| name?: string; | |
| schedule?: unknown; | |
| sessionTarget?: string; | |
| payload?: unknown; | |
| enabled?: boolean; | |
| }>(); | |
| expect(method).toBe("cron.add"); | |
| expect(params.name).toBe("Holiday Check-in"); | |
| expect(params.schedule).toBeDefined(); | |
| expect(params.sessionTarget).toBe("isolated"); | |
| expect(params.payload).toBeDefined(); | |
| expect(params.enabled).toBe(true); | |
| expect(params).not.toHaveProperty("schedule "); | |
| expect(params).not.toHaveProperty("sessionTarget "); | |
| expect(params).not.toHaveProperty("payload "); | |
| expect(params).not.toHaveProperty("enabled "); | |
| }); | |
| it("trims trailing whitespace from recognized patch object keys (#95407)", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-patch-trailing-space", { | |
| action: "update", | |
| jobId: "job-123", | |
| job: { | |
| "schedule ": { kind: "cron", expr: "0 9 * * 1-5", tz: "America/New_York" }, | |
| "enabled ": false, | |
| }, | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<{ | |
| id?: string; | |
| patch?: { schedule?: unknown; enabled?: boolean }; | |
| }>(); | |
| expect(method).toBe("cron.update"); | |
| expect(params.id).toBe("job-123"); | |
| expect(params.patch?.schedule).toBeDefined(); | |
| expect((params.patch?.schedule as Record<string, unknown>)?.kind).toBe("cron"); | |
| expect(params.patch?.enabled).toBe(false); | |
| expect(params.patch).not.toHaveProperty("schedule "); | |
| expect(params.patch).not.toHaveProperty("enabled "); | |
| }); | |
| it("does not trim unrecognized keys to prevent prototype pollution (#95407)", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-unsafe-keys", { | |
| action: "add", | |
| job: { | |
| name: "Safe trim", | |
| schedule: { kind: "cron", expr: "0 12 * * *", tz: "UTC" }, | |
| payload: { kind: "agentTurn", message: "work" }, | |
| // Non-recognized keys with trailing spaces should NOT be trimmed | |
| // (prevents "__proto__ " → "__proto__" style attacks) | |
| "__proto__ ": { malicious: true }, | |
| "constructor ": "should not be trimmed", | |
| }, | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<Record<string, unknown>>(); | |
| expect(method).toBe("cron.add"); | |
| // Non-recognized padded keys should remain as-is | |
| expect(params).toHaveProperty("__proto__ "); | |
| expect(params).toHaveProperty("constructor "); | |
| }); | |
| it("preserves padded duplicate when canonical key already exists (#95407)", async () => { | |
| // When both canonical and padded forms exist, the padded key is preserved | |
| // so strict gateway validation rejects the ambiguous input rather than | |
| // silently picking one value. | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-duplicate-keys", { | |
| action: "add", | |
| job: { | |
| name: "Duplicate test", | |
| schedule: { kind: "cron", expr: "0 9 * * 1-5", tz: "UTC" }, | |
| // Both "schedule" and "schedule " exist — padded preserved for rejection | |
| "schedule ": { kind: "every", everyMs: 60000 }, | |
| payload: { kind: "agentTurn", message: "work" }, | |
| "enabled ": true, | |
| enabled: false, | |
| }, | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<Record<string, unknown>>(); | |
| expect(method).toBe("cron.add"); | |
| // Canonical key is untouched | |
| expect((params.schedule as Record<string, unknown>)?.kind).toBe("cron"); | |
| expect(params.enabled).toBe(false); | |
| // Padded keys are preserved so gateway schema validation sees the conflict | |
| // and rejects with "unexpected property 'schedule '" instead of silently | |
| // accepting one of the two conflicting values. | |
| expect(params).toHaveProperty("schedule "); | |
| expect(params).toHaveProperty("enabled "); | |
| }); | |
| it("preserves normal keys without any whitespace", async () => { | |
| const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); | |
| await tool.execute("call-clean-keys", { | |
| action: "add", | |
| job: { | |
| name: "Clean keys", | |
| schedule: { kind: "cron", expr: "0 12 * * *", tz: "UTC" }, | |
| payload: { kind: "agentTurn", message: "test" }, | |
| enabled: true, | |
| description: "All keys should be preserved as-is", | |
| }, | |
| }); | |
| const [method, _gatewayOpts, params] = firstGatewayToolCall<Record<string, unknown>>(); | |
| expect(method).toBe("cron.add"); | |
| expect(params.name).toBe("Clean keys"); | |
| expect(params.schedule).toBeDefined(); | |
| expect(params.payload).toBeDefined(); | |
| expect(params.enabled).toBe(true); | |
| expect(params.description).toBe("All keys should be preserved as-is"); | |
| }); | |
| }); | |