// Cron edit register tests cover cron edit command registration and option wiring. import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { defaultRuntime } from "../../runtime.js"; const callGatewayFromCli = vi.fn(); vi.mock("../gateway-rpc.js", async () => { const actual = await vi.importActual("../gateway-rpc.js"); return { ...actual, callGatewayFromCli: (...args: Parameters) => callGatewayFromCli(...args), }; }); const { registerCronEditCommand } = await import("./register.cron-edit.js"); function createCronProgram(): Command { const program = new Command(); program.exitOverride(); registerCronEditCommand(program); return program; } async function expectCronEditRejection(args: string[], message: string): Promise { const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); try { await expect( createCronProgram().parseAsync(["edit", "job-1", ...args], { from: "user" }), ).rejects.toMatchObject({ name: "ExitError", code: 1 }); expect(errorSpy).toHaveBeenCalledExactlyOnceWith(expect.stringContaining(message)); expect(callGatewayFromCli).not.toHaveBeenCalled(); } finally { errorSpy.mockRestore(); } } describe("cron edit command", () => { beforeEach(() => { callGatewayFromCli.mockReset(); callGatewayFromCli.mockResolvedValue({ ok: true }); }); it("documents that --best-effort-deliver implies announce mode when used alone (#83908)", () => { const editCommand = createCronProgram().commands.find((command) => command.name() === "edit"); const help = editCommand?.helpInformation() ?? ""; expect(help).toContain("--best-effort-deliver"); expect(help).toContain("--display-name "); expect(help).toContain("--clear-display-name"); expect(help).toContain("--on-exit "); expect(help).toContain("--on-exit-cwd "); expect(help).toContain("main|isolated|current|session:"); expect(help).toMatch(/also\s+implies --announce when used alone/); }); it("accepts --json as the explicit machine-output spelling", async () => { await createCronProgram().parseAsync(["edit", "job-1", "--enable", "--json"], { from: "user", }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { enabled: true }, }); }); it("rethrows contradictory options in JSON mode without accessing the Gateway", async () => { const originalArgv = process.argv; process.argv = ["node", "openclaw", "cron", "edit", "job-1", "--json"]; try { await expect( createCronProgram() .parseAsync(["edit", "job-1", "--enable", "--disable", "--json"], { from: "user" }) .then(() => undefined), ).rejects.toThrow("Choose --enable or --disable, not both"); expect(callGatewayFromCli).not.toHaveBeenCalled(); } finally { process.argv = originalArgv; } }); it("updates the human-readable display name without changing the job name", async () => { await createCronProgram().parseAsync(["edit", "job-1", "--display-name", "Daily summary"], { from: "user", }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { displayName: "Daily summary" }, }); }); it.each(["", " "])("rejects a blank --display-name value", async (value) => { await expectCronEditRejection(["--display-name", value], "--display-name must not be blank"); }); it("clears the display name and restores the stable name fallback", async () => { await createCronProgram().parseAsync(["edit", "job-1", "--clear-display-name"], { from: "user", }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { displayName: null }, }); }); it("rejects combining display-name set and clear flags", async () => { await expectCronEditRejection( ["--display-name", "Daily summary", "--clear-display-name"], "Use --display-name or --clear-display-name, not both", ); }); it("updates one pacing bound while preserving the other", async () => { callGatewayFromCli.mockImplementation(async (method: string) => { if (method === "cron.get") { return { id: "job-1", pacing: { min: "15m", max: "4h" } }; } return { ok: true }; }); const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--pacing-min", "30m"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { pacing: { min: "30m", max: "4h" } }, }); }); it.each(["next.js", "next.js "])( "preserves trigger.once when reading %j (#119916)", async (fileName) => { const fixtureDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "cron-edit-cli-")); const scriptPath = path.join(fixtureDir, fileName); await fs.promises.writeFile( path.join(fixtureDir, "next.js"), "return { fire: false };", "utf8", ); await fs.promises.writeFile(scriptPath, "return { fire: true };", "utf8"); const configRevision = "trigger-script-revision"; callGatewayFromCli.mockImplementation(async (method: string) => { if (method === "cron.get") { return { id: "job-1", configRevision, trigger: { script: "return { fire: false };", once: true }, }; } return { ok: true }; }); try { await createCronProgram().parseAsync(["edit", "job-1", "--trigger-script", scriptPath], { from: "user", }); } finally { await fs.promises.rm(fixtureDir, { recursive: true, force: true }); } expect(callGatewayFromCli).toHaveBeenCalledWith( "cron.update", expect.anything(), expect.objectContaining({ id: "job-1", patch: { trigger: { script: "return { fire: true };", once: true } }, expectedConfigRevision: configRevision, }), ); }, ); it.each([ ["empty", "", undefined], ["whitespace", " ", undefined], ["empty with --clear-trigger", "", "--clear-trigger"], ["whitespace with --clear-trigger", " ", "--clear-trigger"], ])("rejects %s --trigger-script before Gateway access", async (_label, value, clearFlag) => { await expectCronEditRejection( ["--trigger-script", value, ...(clearFlag ? [clearFlag] : [])], "--trigger-script must not be blank", ); }); it.each(["", " "])("rejects blank payload --script %j before Gateway access", async (value) => { await expectCronEditRejection( ["--script", value, "--display-name", "Replacement", "--pacing-min", "30m"], "--script must not be blank", ); }); it("validates trigger script files before Gateway access", async () => { const fixtureDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "cron-edit-invalid-")); const emptyPath = path.join(fixtureDir, "empty.js"); const oversizedPath = path.join(fixtureDir, "oversized.js"); const missingPath = path.join(fixtureDir, "missing.js"); await Promise.all([ fs.promises.writeFile(emptyPath, " \n", "utf8"), fs.promises.writeFile(oversizedPath, "x".repeat(65_537), "utf8"), ]); try { await expectCronEditRejection( ["--pacing-min", "30m", "--trigger-script", emptyPath], "Trigger script must not be empty", ); await expectCronEditRejection( ["--pacing-min", "30m", "--trigger-script", oversizedPath], "Trigger script exceeds 65536 bytes", ); await expectCronEditRejection( ["--pacing-min", "30m", "--trigger-script", missingPath], "ENOENT", ); } finally { await fs.promises.rm(fixtureDir, { recursive: true, force: true }); } }); it("reuses one versioned snapshot for combined pacing and tool edits", async () => { const configRevision = "current-job-revision"; callGatewayFromCli.mockImplementation(async (method: string) => { if (method === "cron.get") { return { id: "job-1", configRevision, pacing: { min: "15m", max: "4h" }, payload: { kind: "agentTurn", message: "hello" }, }; } return { ok: true }; }); await createCronProgram().parseAsync( ["edit", "job-1", "--pacing-min", "30m", "--tools", "read"], { from: "user" }, ); expect(callGatewayFromCli.mock.calls.filter(([method]) => method === "cron.get")).toHaveLength( 1, ); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { pacing: { min: "30m", max: "4h" }, payload: { kind: "agentTurn", toolsAllow: ["read"] }, }, expectedConfigRevision: configRevision, }); }); it.each(["read", ""])("rejects --tools %j combined with --clear-tools", async (tools) => { const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); try { await expect( createCronProgram().parseAsync( ["edit", "job-1", "--pacing-min", "30m", "--tools", tools, "--clear-tools"], { from: "user" }, ), ).rejects.toMatchObject({ name: "ExitError", code: 1 }); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Use --tools or --clear-tools, not both"), ); expect(callGatewayFromCli).not.toHaveBeenCalled(); } finally { errorSpy.mockRestore(); } }); it.each([ { label: "empty --agent", args: ["--agent", ""], message: "--agent must not be blank", }, { label: "whitespace --agent", args: ["--agent", " "], message: "--agent must not be blank", }, { label: "empty --agent with --clear-agent", args: ["--agent", "", "--clear-agent"], message: "--agent must not be blank", }, { label: "whitespace --agent with --clear-agent", args: ["--agent", " ", "--clear-agent"], message: "--agent must not be blank", }, { label: "--agent with --clear-agent", args: ["--agent", "main", "--clear-agent"], message: "Use --agent or --clear-agent, not both", }, { label: "empty --session-key", args: ["--session-key", ""], message: "--session-key must not be blank", }, { label: "whitespace --session-key", args: ["--session-key", " "], message: "--session-key must not be blank", }, { label: "empty --session-key with --clear-session-key", args: ["--session-key", "", "--clear-session-key"], message: "--session-key must not be blank", }, { label: "whitespace --session-key with --clear-session-key", args: ["--session-key", " ", "--clear-session-key"], message: "--session-key must not be blank", }, { label: "--session-key with --clear-session-key", args: ["--session-key", "agent:main:main", "--clear-session-key"], message: "Use --session-key or --clear-session-key, not both", }, ])("rejects $label", async ({ args, message }) => { await expectCronEditRejection(args, message); }); it.each([ { flag: "--best-effort-deliver", bestEffort: true, delivery: { mode: "announce", bestEffort: true }, }, { flag: "--no-best-effort-deliver", bestEffort: false, delivery: { bestEffort: false }, }, ])("keeps $flag-only edits delivery-only (#83908)", async ({ flag, bestEffort, delivery }) => { await createCronProgram().parseAsync(["edit", "job-1", flag], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith( "cron.update", expect.objectContaining({ bestEffortDeliver: bestEffort }), { id: "job-1", patch: { delivery } }, ); }); it("does not set delivery mode to announce when disabling best-effort on payload edits", async () => { const program = createCronProgram(); await program.parseAsync( ["edit", "job-1", "--no-best-effort-deliver", "--message", "new message"], { from: "user" }, ); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { payload: { kind: "agentTurn", message: "new message", }, delivery: { bestEffort: false, }, }, }); }); it("preserves timezone without copying stale stagger when --cron replaces expression (#92291)", async () => { callGatewayFromCli.mockImplementation(async (method: string) => { if (method === "cron.get") { return { id: "job-1", schedule: { kind: "cron", expr: "0 * * * *", tz: "America/Phoenix", staggerMs: 120_000, }, }; } return { ok: true }; }); const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--cron", "0 5 * * *"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.get", expect.anything(), { id: "job-1" }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { schedule: { kind: "cron", expr: "0 5 * * *", tz: "America/Phoenix", staggerMs: undefined, }, }, }); }); it("allows --tz override when --cron replaces expression (#92291)", async () => { const program = createCronProgram(); await program.parseAsync( ["edit", "job-1", "--cron", "0 5 * * *", "--tz", "UTC", "--stagger", "10s"], { from: "user" }, ); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { schedule: { kind: "cron", expr: "0 5 * * *", tz: "UTC", staggerMs: 10000, }, }, }); expect(callGatewayFromCli).not.toHaveBeenCalledWith("cron.list", expect.anything(), { includeDisabled: true, limit: expect.any(Number), offset: expect.any(Number), }); }); it("preserves timezone when --cron edits stagger metadata (#92291)", async () => { callGatewayFromCli.mockImplementation(async (method: string) => { if (method === "cron.get") { return { id: "job-1", schedule: { kind: "cron", expr: "0 * * * *", tz: "America/Phoenix", staggerMs: 120_000, }, }; } return { ok: true }; }); const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--cron", "0 5 * * *", "--stagger", "10s"], { from: "user", }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { schedule: { kind: "cron", expr: "0 5 * * *", tz: "America/Phoenix", staggerMs: 10000, }, }, }); }); describe.each(["0", "12"])("timeout-only edits with %s seconds", (timeout) => { it.each([ { kind: "agentTurn", payload: { kind: "agentTurn", message: "hello" }, }, { kind: "command", payload: { kind: "command", argv: ["sh", "-lc", "echo ok"] }, }, ])("preserves $kind payload kind for timeout-only edits", async ({ kind, payload }) => { callGatewayFromCli.mockImplementation(async (method: string) => { if (method === "cron.get") { return { id: "job-1", payload }; } return { ok: true }; }); const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--timeout-seconds", timeout], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.get", expect.anything(), { id: "job-1", }); expect(callGatewayFromCli.mock.calls.some(([method]) => method === "cron.list")).toBe(false); expect(callGatewayFromCli).toHaveBeenCalledWith( "cron.update", expect.objectContaining({ timeoutSeconds: timeout }), { id: "job-1", patch: { payload: { kind, timeoutSeconds: Number(timeout), }, }, }, ); }); it.each([ { kind: "script", payload: { kind: "script", script: "return { notify: 'hello' }", timeoutSeconds: 5 }, error: "Use --script-timeout-seconds for script jobs", }, { kind: "systemEvent", payload: { kind: "systemEvent", text: "hello" }, error: "--timeout-seconds is not supported for systemEvent jobs", }, { kind: "heartbeat", payload: { kind: "heartbeat" }, error: "--timeout-seconds is not supported for heartbeat jobs", }, ])( "rejects timeout-only edits for stored $kind payloads before cron.update", async ({ payload, error }) => { callGatewayFromCli.mockImplementation(async (method: string) => { if (method === "cron.get") { return { id: "job-1", payload }; } return { ok: true }; }); const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); try { await expect( createCronProgram().parseAsync(["edit", "job-1", "--timeout-seconds", timeout], { from: "user", }), ).rejects.toMatchObject({ name: "ExitError", code: 1 }); expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(error)); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.get", expect.anything(), { id: "job-1", }); expect(callGatewayFromCli.mock.calls.some(([method]) => method === "cron.update")).toBe( false, ); } finally { errorSpy.mockRestore(); } }, ); }); it("rejects generic timeout combined with an explicit systemEvent before cron.update", async () => { const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); try { await expect( createCronProgram().parseAsync( ["edit", "job-1", "--system-event", "hello", "--timeout-seconds", "12"], { from: "user" }, ), ).rejects.toMatchObject({ name: "ExitError", code: 1 }); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("--timeout-seconds is not supported for systemEvent jobs"), ); expect(callGatewayFromCli.mock.calls.some(([method]) => method === "cron.update")).toBe( false, ); } finally { errorSpy.mockRestore(); } }); it.each([ ["--script", "missing-script.js"], ["--script-tool-budget", "3"], ["--script-timeout-seconds", "20"], ])( "rejects generic timeout combined with script option %s before cron.update", async (flag, value) => { const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); try { await expect( createCronProgram().parseAsync( ["edit", "job-1", "--timeout-seconds", "12", flag, value], { from: "user" }, ), ).rejects.toMatchObject({ name: "ExitError", code: 1 }); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Use --script-timeout-seconds for script jobs"), ); expect(callGatewayFromCli.mock.calls.some(([method]) => method === "cron.update")).toBe( false, ); } finally { errorSpy.mockRestore(); } }, ); it("updates script timeout with the script-specific option", async () => { await createCronProgram().parseAsync(["edit", "job-1", "--script-timeout-seconds", "20"], { from: "user", }); expect(callGatewayFromCli).toHaveBeenCalledWith( "cron.update", expect.objectContaining({ scriptTimeoutSeconds: "20" }), { id: "job-1", patch: { payload: { kind: "script", timeoutSeconds: 20, }, }, }, ); }); it("falls back to cron.list when an older Gateway does not support cron.get", async () => { const unknownMethodError = Object.assign(new Error("unknown method: cron.get"), { name: "GatewayClientRequestError", gatewayCode: "INVALID_REQUEST", }); callGatewayFromCli.mockImplementation( async (method: string, _opts: unknown, params?: unknown) => { if (method === "cron.get") { throw unknownMethodError; } if (method === "cron.list") { const offset = (params as { offset?: number }).offset ?? 0; if (offset === 0) { return { jobs: [{ id: "other-job", schedule: { kind: "cron", expr: "0 * * * *" } }], hasMore: true, nextOffset: 200, }; } return { jobs: [ { id: "job-1", schedule: { kind: "cron", expr: "0 */2 * * *", staggerMs: 300_000 }, }, ], hasMore: false, nextOffset: null, }; } return { ok: true, params }; }, ); const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--exact"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.get", expect.anything(), { id: "job-1", }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.list", expect.anything(), { includeDisabled: true, limit: 200, offset: 0, }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.list", expect.anything(), { includeDisabled: true, limit: 200, offset: 200, }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { schedule: { kind: "cron", expr: "0 */2 * * *", staggerMs: 0, }, }, }); }); it("clears the model override with --clear-model (CLI parity with cron.update model:null)", async () => { const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--clear-model"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith( "cron.update", expect.objectContaining({ clearModel: true }), { id: "job-1", patch: { payload: { kind: "agentTurn", model: null, }, }, }, ); }); it.each([ ["--model", "", "--clear-model"], ["--model", " ", "--clear-model"], ["--thinking", "", "--clear-thinking"], ["--thinking", " ", "--clear-thinking"], ])("rejects blank %s %j combined with %s", async (flag, value, clearFlag) => { await expectCronEditRejection( [flag, value, clearFlag], `Use ${flag} or ${clearFlag}, not both`, ); }); it("stores an explicit wildcard with --clear-tools", async () => { callGatewayFromCli.mockImplementation(async (method: string) => { if (method === "cron.get") { return { id: "job-1", payload: { kind: "agentTurn", message: "hello" } }; } return { ok: true }; }); const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--clear-tools"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith( "cron.update", expect.objectContaining({ clearTools: true }), { id: "job-1", patch: { payload: { kind: "agentTurn", toolsAllow: ["*"], }, }, }, ); }); it.each( [ { kind: "agentTurn", payload: { kind: "agentTurn", message: "hello" } }, { kind: "command", payload: { kind: "command", argv: ["echo", "hello"] } }, { kind: "script", payload: { kind: "script", script: "return { notify: 'hello' }" } }, { kind: "systemEvent", payload: { kind: "systemEvent", text: "hello" } }, ].flatMap((entry) => [ { ...entry, tools: "read,write", toolsAllow: ["read", "write"] }, { ...entry, tools: "", toolsAllow: [] }, ]), )( "preserves $kind payloads when editing their tool allowlist to '$tools'", async ({ kind, payload, tools, toolsAllow }) => { callGatewayFromCli.mockImplementation(async (method: string) => { if (method === "cron.get") { return { id: "job-1", payload }; } return { ok: true }; }); await createCronProgram().parseAsync(["edit", "job-1", "--tools", tools], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.get", expect.anything(), { id: "job-1", }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { payload: { kind, toolsAllow } }, }); }, ); it.each([ { kind: "agentTurn", payload: { kind: "agentTurn", message: "hello" } }, { kind: "command", payload: { kind: "command", argv: ["echo", "hello"] } }, { kind: "script", payload: { kind: "script", script: "return { notify: 'hello' }" } }, { kind: "systemEvent", payload: { kind: "systemEvent", text: "hello" } }, ])("preserves $kind payloads when clearing their tool allowlist", async ({ kind, payload }) => { callGatewayFromCli.mockImplementation(async (method: string) => { if (method === "cron.get") { return { id: "job-1", payload }; } return { ok: true }; }); const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--clear-tools"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { payload: { kind, toolsAllow: ["*"] } }, }); }); it.each([ { label: "command with a restricted allowlist", payload: { kind: "command", argv: ["echo", "hello"] }, toolArgs: ["--tools", "read,write"], toolsAllow: ["read", "write"], }, { label: "command with a cleared allowlist", payload: { kind: "command", argv: ["echo", "hello"] }, toolArgs: ["--clear-tools"], toolsAllow: ["*"], }, { label: "agent turn with a restricted allowlist", payload: { kind: "agentTurn", message: "hello" }, toolArgs: ["--tools", "read,write"], toolsAllow: ["read", "write"], }, { label: "agent turn with a cleared allowlist", payload: { kind: "agentTurn", message: "hello" }, toolArgs: ["--clear-tools"], toolsAllow: ["*"], }, ])( "preserves the existing $label when editing its timeout", async ({ payload, toolArgs, toolsAllow }) => { callGatewayFromCli.mockImplementation(async (method: string) => { if (method === "cron.get") { return { id: "job-1", payload }; } return { ok: true }; }); const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--timeout-seconds", "12", ...toolArgs], { from: "user", }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.get", expect.anything(), { id: "job-1", }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { payload: { kind: payload.kind, timeoutSeconds: 12, toolsAllow, }, }, }); }, ); it("keeps explicit conversational payload changes independent of existing job reads", async () => { const program = createCronProgram(); await program.parseAsync( ["edit", "job-1", "--message", "new message", "--timeout-seconds", "12", "--tools", "read"], { from: "user" }, ); expect(callGatewayFromCli.mock.calls.some(([method]) => method === "cron.get")).toBe(false); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { payload: { kind: "agentTurn", message: "new message", timeoutSeconds: 12, toolsAllow: ["read"], }, }, }); }); it.each([ { duration: "0", cooldownMs: 0 }, { duration: "0s", cooldownMs: 0 }, { duration: "0m", cooldownMs: 0 }, { duration: "30s", cooldownMs: 30_000 }, { duration: "1h30m", cooldownMs: 5_400_000 }, ])("accepts failure alert cooldown $duration", async ({ duration, cooldownMs }) => { const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--failure-alert-cooldown", duration], { from: "user", }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { failureAlert: { cooldownMs } }, }); }); it.each(["-1s", "not-a-duration", "999999999999999999d"])( "rejects invalid failure alert cooldown %s", async (duration) => { const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); const program = createCronProgram(); try { await expect( program.parseAsync(["edit", "job-1", "--failure-alert-cooldown", duration], { from: "user", }), ).rejects.toMatchObject({ name: "ExitError", code: 1 }); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Invalid --failure-alert-cooldown."), ); expect(callGatewayFromCli).not.toHaveBeenCalled(); } finally { errorSpy.mockRestore(); } }, ); it("clears the thinking override with --clear-thinking (CLI parity with cron.update thinking:null)", async () => { const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--clear-thinking"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith( "cron.update", expect.objectContaining({ clearThinking: true }), { id: "job-1", patch: { payload: { kind: "agentTurn", thinking: null, }, }, }, ); }); it("rejects combining --thinking with --clear-thinking", async () => { const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); const program = createCronProgram(); await expect( program.parseAsync(["edit", "job-1", "--thinking", "high", "--clear-thinking"], { from: "user", }), ).rejects.toMatchObject({ name: "ExitError", code: 1 }); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Use --thinking or --clear-thinking, not both"), ); expect(callGatewayFromCli).not.toHaveBeenCalled(); errorSpy.mockRestore(); }); it("documents the --clear-model flag alongside the sibling --clear-tools", () => { const editCommand = createCronProgram().commands.find((command) => command.name() === "edit"); const help = editCommand?.helpInformation() ?? ""; expect(help).toContain("--clear-model"); expect(help).toContain("--clear-thinking"); expect(help).toContain("--clear-tools"); }); it("clears the delivery channel with --clear-channel (CLI parity with cron.update channel:null)", async () => { const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--clear-channel"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith( "cron.update", expect.objectContaining({ clearChannel: true }), { id: "job-1", patch: { delivery: { channel: null } }, }, ); }); it("clears the delivery destination with --clear-to", async () => { const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--clear-to"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith( "cron.update", expect.objectContaining({ clearTo: true }), { id: "job-1", patch: { delivery: { to: null } }, }, ); }); it("clears the delivery thread id with --clear-thread-id", async () => { const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--clear-thread-id"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith( "cron.update", expect.objectContaining({ clearThreadId: true }), { id: "job-1", patch: { delivery: { threadId: null } }, }, ); }); it("clears the delivery account override with --clear-account", async () => { const program = createCronProgram(); await program.parseAsync(["edit", "job-1", "--clear-account"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith( "cron.update", expect.objectContaining({ clearAccount: true }), { id: "job-1", patch: { delivery: { accountId: null } }, }, ); }); it.each([ { set: "--channel", value: "telegram", clear: "--clear-channel" }, { set: "--to", value: "12345", clear: "--clear-to" }, { set: "--thread-id", value: "42", clear: "--clear-thread-id" }, { set: "--account", value: "writer", clear: "--clear-account" }, ])("rejects $set combined with $clear", async ({ set, value, clear }) => { const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); const program = createCronProgram(); await expect( program.parseAsync(["edit", "job-1", set, value, clear], { from: "user" }), ).rejects.toMatchObject({ name: "ExitError", code: 1 }); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining(`Use ${set} or ${clear}, not both`), ); expect(callGatewayFromCli).not.toHaveBeenCalled(); errorSpy.mockRestore(); }); it.each(["", " "])("rejects blank --command-cwd %j", async (value) => { await expectCronEditRejection(["--command-cwd", value], "--command-cwd must not be blank"); }); it("rejects blank --command-cwd before loading an existing job", async () => { await expectCronEditRejection( ["--pacing-min", "30m", "--command-cwd", " "], "--command-cwd must not be blank", ); }); it.each(["", " "])("preserves --command-input %j as command stdin", async (value) => { await createCronProgram().parseAsync(["edit", "job-1", "--command-input", value], { from: "user", }); expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { id: "job-1", patch: { payload: { kind: "command", input: value } }, }); }); it("rejects --webhook combined with a delivery clear flag", async () => { const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); const program = createCronProgram(); await expect( program.parseAsync( ["edit", "job-1", "--webhook", "https://example.invalid/hook", "--clear-channel"], { from: "user" }, ), ).rejects.toMatchObject({ name: "ExitError", code: 1 }); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("--webhook cannot be combined with chat delivery options."), ); expect(callGatewayFromCli).not.toHaveBeenCalled(); errorSpy.mockRestore(); }); it.each(["", "not-a-url"])("rejects invalid --webhook %j before gateway RPC", async (value) => { await expectCronEditRejection(["--webhook", value], "--webhook must be a valid http(s) URL"); }); it("documents the delivery clear flags alongside the sibling --clear-model", () => { const editCommand = createCronProgram().commands.find((command) => command.name() === "edit"); const help = editCommand?.helpInformation() ?? ""; expect(help).toContain("--clear-channel"); expect(help).toContain("--clear-to"); expect(help).toContain("--clear-thread-id"); expect(help).toContain("--clear-account"); }); it.each([ ["--channel", "telegram"], ["--to", "+1234567890"], ["--account", "coordinator"], ["--thread-id", "42"], ])("rejects explicit chat delivery %s on main systemEvent cron edit", async (flag, value) => { const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); const program = createCronProgram(); await expect( program.parseAsync( ["edit", "job-1", "--session", "main", "--system-event", "wakeup", flag, value], { from: "user" }, ), ).rejects.toMatchObject({ name: "ExitError", code: 1 }); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining( "--channel, --to, --account, and --thread-id require a non-main agentTurn or command job with delivery.", ), ); expect(callGatewayFromCli).not.toHaveBeenCalled(); errorSpy.mockRestore(); }); });