From ca76e2fedc8ef9ba5d2e9ca6da482d33b678fa90 Mon Sep 17 00:00:00 2001 From: Bruno Lorente Date: Wed, 1 Apr 2026 22:41:19 +0200 Subject: [PATCH] fix(cron-tool): add typed properties to job/patch schemas (#55043) Merged via squash. Prepared head SHA: 979bb0e8b78bd9888767d14ccbdb01a45b7be889 Co-authored-by: brunolorente <127802443+brunolorente@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/agents/tools/cron-tool.schema.test.ts | 171 ++++++++++++ src/agents/tools/cron-tool.test.ts | 279 ++++++++++++++++++++ src/agents/tools/cron-tool.ts | 283 ++++++++++++++++---- src/cron/normalize.test.ts | 308 ++++++++++++++++++++++ src/cron/normalize.ts | 146 ++++++++-- src/cron/service.jobs.test.ts | 119 +++++++++ src/cron/service/jobs.ts | 10 + src/cron/types.ts | 4 +- src/gateway/protocol/schema/cron.ts | 13 +- 10 files changed, 1260 insertions(+), 74 deletions(-) create mode 100644 src/agents/tools/cron-tool.schema.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b402d104b65..e99c2ee6270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Plugins/commands: pass the active host `sessionKey` into plugin command contexts, and include `sessionId` when it is already available from the active session entry, so bundled and third-party commands can resolve the current conversation reliably. (#59044) Thanks @jalehman. - Agents/auth: honor `models.providers.*.authHeader` for pi embedded runner model requests by injecting `Authorization: Bearer ` when requested. (#54390) Thanks @lndyzwdxhs. - UI/compaction: keep the compaction indicator in a retry-pending state until the run actually finishes, so the UI does not show `Context compacted` before compaction actually finishes. (#55132) Thanks @mpz4life. +- Cron/tool schemas: keep cron tool schemas strict-model-friendly while still preserving `failureAlert=false`, nullable `agentId`/`sessionKey`, and flattened add/update recovery for the newly exposed cron job fields. (#55043) Thanks @brunolorente. ## 2026.4.2 diff --git a/src/agents/tools/cron-tool.schema.test.ts b/src/agents/tools/cron-tool.schema.test.ts new file mode 100644 index 00000000000..7733c5b91c3 --- /dev/null +++ b/src/agents/tools/cron-tool.schema.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { CronToolSchema } from "./cron-tool.js"; + +/** Walk a TypeBox schema by dot-separated property path and return sorted keys. */ +function keysAt(schema: Record, path: string): string[] { + let cursor: Record | undefined = schema; + for (const segment of path.split(".")) { + const props = cursor?.["properties"] as Record> | undefined; + cursor = props?.[segment]; + } + const leaf = cursor?.["properties"] as Record | undefined; + return leaf ? Object.keys(leaf).toSorted() : []; +} + +function propertyAt( + schema: Record, + path: string, +): Record | undefined { + let cursor: Record | undefined = schema; + for (const segment of path.split(".")) { + const props = cursor?.["properties"] as Record> | undefined; + cursor = props?.[segment]; + } + return cursor; +} + +describe("CronToolSchema", () => { + // Regression: models like GPT-5.4 rely on these fields to populate job/patch. + // If a field is removed from this list the test must be updated intentionally. + + it("job exposes the expected top-level fields", () => { + expect(keysAt(CronToolSchema as Record, "job")).toEqual( + [ + "agentId", + "deleteAfterRun", + "delivery", + "description", + "enabled", + "failureAlert", + "name", + "payload", + "schedule", + "sessionKey", + "sessionTarget", + "wakeMode", + ].toSorted(), + ); + }); + + it("patch exposes the expected top-level fields", () => { + expect(keysAt(CronToolSchema as Record, "patch")).toEqual( + [ + "agentId", + "deleteAfterRun", + "delivery", + "description", + "enabled", + "failureAlert", + "name", + "payload", + "schedule", + "sessionKey", + "sessionTarget", + "wakeMode", + ].toSorted(), + ); + }); + + it("job.schedule exposes kind, at, everyMs, anchorMs, expr, tz, staggerMs", () => { + expect(keysAt(CronToolSchema as Record, "job.schedule")).toEqual( + ["anchorMs", "at", "everyMs", "expr", "kind", "staggerMs", "tz"].toSorted(), + ); + }); + + it("marks staggerMs as cron-only in both job and patch schedule schemas", () => { + const jobStagger = propertyAt( + CronToolSchema as Record, + "job.schedule.staggerMs", + ); + const patchStagger = propertyAt( + CronToolSchema as Record, + "patch.schedule.staggerMs", + ); + + expect(jobStagger?.description).toBe("Random jitter in ms (kind=cron)"); + expect(patchStagger?.description).toBe("Random jitter in ms (kind=cron)"); + }); + + it("job.delivery exposes mode, channel, to, bestEffort, accountId, failureDestination", () => { + expect(keysAt(CronToolSchema as Record, "job.delivery")).toEqual( + ["accountId", "bestEffort", "channel", "failureDestination", "mode", "to"].toSorted(), + ); + }); + + it("job.payload exposes kind, text, message, model, thinking and extras", () => { + expect(keysAt(CronToolSchema as Record, "job.payload")).toEqual( + [ + "allowUnsafeExternalContent", + "fallbacks", + "kind", + "lightContext", + "message", + "model", + "text", + "thinking", + "toolsAllow", + "timeoutSeconds", + ].toSorted(), + ); + }); + + it("job.payload includes fallbacks", () => { + expect(keysAt(CronToolSchema as Record, "job.payload")).toContain("fallbacks"); + }); + + it("patch.payload exposes agentTurn fallback overrides", () => { + expect(keysAt(CronToolSchema as Record, "patch.payload")).toEqual( + [ + "allowUnsafeExternalContent", + "fallbacks", + "kind", + "lightContext", + "message", + "model", + "text", + "thinking", + "toolsAllow", + "timeoutSeconds", + ].toSorted(), + ); + }); + + it("job.failureAlert exposes after, channel, to, cooldownMs, mode, accountId", () => { + expect(keysAt(CronToolSchema as Record, "job.failureAlert")).toEqual( + ["accountId", "after", "channel", "cooldownMs", "mode", "to"].toSorted(), + ); + }); + + it("job.failureAlert also allows boolean false", () => { + const root = (CronToolSchema as Record).properties as + | Record; type?: unknown }> + | undefined; + const jobProps = root?.job?.properties as + | Record + | undefined; + const schema = jobProps?.failureAlert; + expect(schema?.type).toEqual(["object", "boolean"]); + expect(schema?.not?.const).toBe(true); + }); + + it("job.agentId and job.sessionKey accept null for clear/keep-unset flows", () => { + const root = (CronToolSchema as Record).properties as + | Record }> + | undefined; + const jobProps = root?.job?.properties as Record | undefined; + + expect(jobProps?.agentId?.type).toEqual(["string", "null"]); + expect(jobProps?.sessionKey?.type).toEqual(["string", "null"]); + }); + + it("patch.payload.toolsAllow accepts null for clear flows", () => { + const root = (CronToolSchema as Record).properties as + | Record }> + | undefined; + const patchProps = root?.patch?.properties as + | Record }> + | undefined; + + expect(patchProps?.payload?.properties?.toolsAllow?.type).toEqual(["array", "null"]); + }); +}); diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 9e699086db9..243e59586f6 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -206,6 +206,59 @@ describe("cron tool", () => { expect(call?.params?.agentId).toBeNull(); }); + it("passes through failureAlert=false for add", async () => { + const tool = createTestCronTool(); + await tool.execute("call-disable-alerts-add", { + action: "add", + job: { + name: "reminder", + schedule: { at: new Date(123).toISOString() }, + payload: { kind: "agentTurn", message: "hello" }, + failureAlert: false, + }, + }); + + const params = expectSingleGatewayCallMethod("cron.add") as + | { failureAlert?: unknown } + | undefined; + expect(params?.failureAlert).toBe(false); + }); + + it("recovers flattened add params for failureAlert and payload extras", async () => { + const tool = createTestCronTool(); + await tool.execute("call-flat-add-extras", { + action: "add", + name: "reminder", + schedule: { at: new Date(123).toISOString() }, + message: "hello", + lightContext: true, + fallbacks: [" openrouter/gpt-4.1-mini ", "anthropic/claude-haiku-3-5"], + toolsAllow: [" exec ", " read "], + failureAlert: { after: 3, cooldownMs: 60_000 }, + }); + + const params = expectSingleGatewayCallMethod("cron.add") as + | { + payload?: { + kind?: string; + message?: string; + lightContext?: boolean; + fallbacks?: string[]; + toolsAllow?: string[]; + }; + failureAlert?: { after?: number; cooldownMs?: number }; + } + | undefined; + expect(params?.payload).toEqual({ + kind: "agentTurn", + message: "hello", + lightContext: true, + fallbacks: ["openrouter/gpt-4.1-mini", "anthropic/claude-haiku-3-5"], + toolsAllow: ["exec", "read"], + }); + expect(params?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000 }); + }); + it("stamps cron.add with caller sessionKey when missing", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); @@ -465,6 +518,20 @@ describe("cron tool", () => { expect(delivery).toEqual({ mode: "none" }); }); + it("preserves explicit mode-less delivery objects for add", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const delivery = await executeAddAndReadDelivery({ + callId: "call-implicit-announce", + agentSessionKey: "agent:main:discord:dm:buddy", + delivery: { channel: "telegram", to: "123" }, + }); + expect(delivery).toEqual({ + channel: "telegram", + to: "123", + }); + }); + it("does not infer announce delivery when mode is webhook", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); const delivery = await executeAddAndReadDelivery({ @@ -551,4 +618,216 @@ describe("cron tool", () => { expect(params?.patch?.sessionTarget).toBe("main"); expect(params?.patch?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000 }); }); + it("passes through failureAlert=false for update", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const tool = createTestCronTool(); + await tool.execute("call-update-disable-alerts", { + action: "update", + id: "job-4", + patch: { failureAlert: false }, + }); + + const params = expectSingleGatewayCallMethod("cron.update") as + | { id?: string; patch?: { failureAlert?: unknown } } + | undefined; + expect(params?.id).toBe("job-4"); + expect(params?.patch?.failureAlert).toBe(false); + }); + + it("recovers flattened payload patch params for update action", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const tool = createTestCronTool(); + await tool.execute("call-update-flat-payload", { + action: "update", + id: "job-3", + message: "run report", + model: " openrouter/deepseek/deepseek-r1 ", + thinking: " high ", + timeoutSeconds: 45, + lightContext: true, + }); + + const params = expectSingleGatewayCallMethod("cron.update") as + | { + id?: string; + patch?: { + payload?: { + kind?: string; + message?: string; + model?: string; + thinking?: string; + timeoutSeconds?: number; + lightContext?: boolean; + }; + }; + } + | undefined; + expect(params?.id).toBe("job-3"); + expect(params?.patch?.payload).toEqual({ + kind: "agentTurn", + message: "run report", + model: "openrouter/deepseek/deepseek-r1", + thinking: "high", + timeoutSeconds: 45, + lightContext: true, + }); + }); + + it("recovers flattened model-only payload patch params for update action", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const tool = createTestCronTool(); + await tool.execute("call-update-flat-model-only", { + action: "update", + id: "job-5", + model: " openrouter/deepseek/deepseek-r1 ", + fallbacks: [" openrouter/gpt-4.1-mini ", "anthropic/claude-haiku-3-5"], + toolsAllow: [" exec ", " read "], + }); + + const params = expectSingleGatewayCallMethod("cron.update") as + | { + id?: string; + patch?: { + payload?: { + kind?: string; + model?: string; + fallbacks?: string[]; + toolsAllow?: string[]; + }; + }; + } + | undefined; + expect(params?.id).toBe("job-5"); + expect(params?.patch?.payload).toEqual({ + kind: "agentTurn", + model: "openrouter/deepseek/deepseek-r1", + fallbacks: ["openrouter/gpt-4.1-mini", "anthropic/claude-haiku-3-5"], + toolsAllow: ["exec", "read"], + }); + }); + + it("rejects malformed flattened fallback-only payload patch params for update action", async () => { + const tool = createTestCronTool(); + + await expect( + tool.execute("call-update-flat-invalid-fallbacks", { + action: "update", + id: "job-9", + fallbacks: [123], + }), + ).rejects.toThrow("patch required"); + expect(callGatewayMock).toHaveBeenCalledTimes(0); + }); + + it("rejects malformed flattened toolsAllow-only payload patch params for update action", async () => { + const tool = createTestCronTool(); + + await expect( + tool.execute("call-update-flat-invalid-tools", { + action: "update", + id: "job-10", + toolsAllow: [123], + }), + ).rejects.toThrow("patch required"); + expect(callGatewayMock).toHaveBeenCalledTimes(0); + }); + + it("infers kind for nested fallback-only payload patches on update", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const tool = createTestCronTool(); + await tool.execute("call-update-nested-fallbacks-only", { + action: "update", + id: "job-6", + patch: { + payload: { + fallbacks: [" openrouter/gpt-4.1-mini ", "anthropic/claude-haiku-3-5"], + }, + }, + }); + + const params = expectSingleGatewayCallMethod("cron.update") as + | { + id?: string; + patch?: { + payload?: { + kind?: string; + fallbacks?: string[]; + }; + }; + } + | undefined; + expect(params?.id).toBe("job-6"); + expect(params?.patch?.payload).toEqual({ + kind: "agentTurn", + fallbacks: ["openrouter/gpt-4.1-mini", "anthropic/claude-haiku-3-5"], + }); + }); + + it("infers kind for nested toolsAllow-only payload patches on update", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const tool = createTestCronTool(); + await tool.execute("call-update-nested-tools-only", { + action: "update", + id: "job-7", + patch: { + payload: { + toolsAllow: [" exec ", " read "], + }, + }, + }); + + const params = expectSingleGatewayCallMethod("cron.update") as + | { + id?: string; + patch?: { + payload?: { + kind?: string; + toolsAllow?: string[]; + }; + }; + } + | undefined; + expect(params?.id).toBe("job-7"); + expect(params?.patch?.payload).toEqual({ + kind: "agentTurn", + toolsAllow: ["exec", "read"], + }); + }); + + it("preserves null toolsAllow payload patches on update", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const tool = createTestCronTool(); + await tool.execute("call-update-clear-tools", { + action: "update", + id: "job-8", + patch: { + payload: { + toolsAllow: null, + }, + }, + }); + + const params = expectSingleGatewayCallMethod("cron.update") as + | { + id?: string; + patch?: { + payload?: { + kind?: string; + toolsAllow?: string[] | null; + }; + }; + } + | undefined; + expect(params?.id).toBe("job-8"); + expect(params?.patch?.payload).toEqual({ + kind: "agentTurn", + toolsAllow: null, + }); + }); }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 1a2a24bba86..36b4f8ee33a 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -1,4 +1,4 @@ -import { Type } from "@sinclair/typebox"; +import { Type, type TSchema } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; import type { CronDelivery, CronMessageChannel } from "../../cron/types.js"; @@ -12,33 +12,252 @@ import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, readGatewayCallOptions, type GatewayCallOptions } from "./gateway.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; -// NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch -// instead of CronAddParamsSchema/CronJobPatchSchema because the gateway schemas -// contain nested unions. Tool schemas need to stay provider-friendly, so we -// accept "any object" here and validate at runtime. +// 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_SCHEDULE_KINDS = ["at", "every", "cron"] as const; const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const; +const CRON_PAYLOAD_KINDS = ["systemEvent", "agentTurn"] as const; +const CRON_DELIVERY_MODES = ["none", "announce", "webhook"] as const; const CRON_RUN_MODES = ["due", "force"] as const; +const CRON_FLAT_PAYLOAD_KEYS = [ + "message", + "text", + "model", + "fallbacks", + "toolsAllow", + "thinking", + "timeoutSeconds", + "lightContext", + "allowUnsafeExternalContent", +] as const; +const CRON_RECOVERABLE_OBJECT_KEYS: ReadonlySet = new Set([ + "name", + "schedule", + "sessionTarget", + "wakeMode", + "payload", + "delivery", + "enabled", + "description", + "deleteAfterRun", + "agentId", + "sessionKey", + "failureAlert", + ...CRON_FLAT_PAYLOAD_KEYS, +]); const REMINDER_CONTEXT_MESSAGES_MAX = 10; const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220; const REMINDER_CONTEXT_TOTAL_MAX = 700; const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n"; +function nullableStringSchema(description: string) { + return Type.Optional( + Type.Unsafe({ + type: ["string", "null"], + description, + }), + ); +} + +function nullableStringArraySchema(description: string) { + return Type.Optional( + Type.Unsafe({ + type: ["array", "null"], + items: { type: "string" }, + description, + }), + ); +} + +function cronPayloadObjectSchema(params: { toolsAllow: TSchema }) { + return Type.Object( + { + kind: optionalStringEnum(CRON_PAYLOAD_KINDS, { description: "Payload type" }), + text: Type.Optional(Type.String({ description: "Message text (kind=systemEvent)" })), + message: Type.Optional(Type.String({ description: "Agent prompt (kind=agentTurn)" })), + model: Type.Optional(Type.String({ description: "Model override" })), + thinking: Type.Optional(Type.String({ description: "Thinking level override" })), + timeoutSeconds: Type.Optional(Type.Number()), + lightContext: Type.Optional(Type.Boolean()), + allowUnsafeExternalContent: Type.Optional(Type.Boolean()), + fallbacks: Type.Optional(Type.Array(Type.String(), { description: "Fallback model ids" })), + toolsAllow: params.toolsAllow, + }, + { additionalProperties: true }, + ); +} + +const CronScheduleSchema = Type.Optional( + Type.Object( + { + kind: optionalStringEnum(CRON_SCHEDULE_KINDS, { description: "Schedule type" }), + at: Type.Optional(Type.String({ description: "ISO-8601 timestamp (kind=at)" })), + everyMs: Type.Optional(Type.Number({ description: "Interval in milliseconds (kind=every)" })), + anchorMs: Type.Optional( + Type.Number({ description: "Optional start anchor in milliseconds (kind=every)" }), + ), + expr: Type.Optional(Type.String({ description: "Cron expression (kind=cron)" })), + tz: Type.Optional(Type.String({ description: "IANA timezone (kind=cron)" })), + staggerMs: Type.Optional(Type.Number({ description: "Random jitter in ms (kind=cron)" })), + }, + { additionalProperties: true }, + ), +); + +const CronPayloadSchema = Type.Optional( + cronPayloadObjectSchema({ + toolsAllow: Type.Optional(Type.Array(Type.String(), { description: "Allowed tool ids" })), + }), +); + +const CronDeliverySchema = Type.Optional( + Type.Object( + { + mode: optionalStringEnum(CRON_DELIVERY_MODES, { description: "Delivery mode" }), + channel: Type.Optional(Type.String({ description: "Delivery channel" })), + to: Type.Optional(Type.String({ description: "Delivery target" })), + bestEffort: Type.Optional(Type.Boolean()), + accountId: Type.Optional(Type.String({ description: "Account target for delivery" })), + failureDestination: Type.Optional( + Type.Object( + { + channel: Type.Optional(Type.String()), + to: Type.Optional(Type.String()), + accountId: Type.Optional(Type.String()), + mode: optionalStringEnum(["announce", "webhook"] as const), + }, + { additionalProperties: true }, + ), + ), + }, + { additionalProperties: true }, + ), +); + +// Keep `false` expressible without reintroducing anyOf/oneOf into the raw tool schema. +// Omitting `failureAlert` means "leave defaults/unchanged"; `false` explicitly disables alerts. +const CronFailureAlertSchema = Type.Optional( + Type.Unsafe | false>({ + type: ["object", "boolean"], + not: { const: true }, + properties: { + after: Type.Optional(Type.Number({ description: "Failures before alerting" })), + channel: Type.Optional(Type.String({ description: "Alert channel" })), + to: Type.Optional(Type.String({ description: "Alert target" })), + cooldownMs: Type.Optional(Type.Number({ description: "Cooldown between alerts in ms" })), + mode: optionalStringEnum(["announce", "webhook"] as const), + accountId: Type.Optional(Type.String()), + }, + additionalProperties: true, + description: "Failure alert object, or false to disable alerts for this job", + }), +); + +const CronJobObjectSchema = Type.Optional( + Type.Object( + { + name: Type.Optional(Type.String({ description: "Job name" })), + schedule: CronScheduleSchema, + sessionTarget: Type.Optional( + Type.String({ + description: 'Session target: "main", "isolated", "current", or "session:"', + }), + ), + wakeMode: optionalStringEnum(CRON_WAKE_MODES, { description: "When to wake the session" }), + payload: CronPayloadSchema, + delivery: CronDeliverySchema, + agentId: nullableStringSchema("Agent id, or null to keep it unset"), + description: Type.Optional(Type.String({ description: "Human-readable description" })), + enabled: Type.Optional(Type.Boolean()), + deleteAfterRun: Type.Optional(Type.Boolean({ description: "Delete after first execution" })), + sessionKey: nullableStringSchema("Explicit session key, or null to clear it"), + failureAlert: CronFailureAlertSchema, + }, + { additionalProperties: true }, + ), +); + +const CronPatchObjectSchema = Type.Optional( + Type.Object( + { + name: Type.Optional(Type.String({ description: "Job name" })), + schedule: Type.Optional( + Type.Object( + { + kind: optionalStringEnum(CRON_SCHEDULE_KINDS, { description: "Schedule type" }), + at: Type.Optional(Type.String({ description: "ISO-8601 timestamp (kind=at)" })), + everyMs: Type.Optional( + Type.Number({ description: "Interval in milliseconds (kind=every)" }), + ), + anchorMs: Type.Optional( + Type.Number({ description: "Optional start anchor in milliseconds (kind=every)" }), + ), + expr: Type.Optional(Type.String({ description: "Cron expression (kind=cron)" })), + tz: Type.Optional(Type.String({ description: "IANA timezone (kind=cron)" })), + staggerMs: Type.Optional( + Type.Number({ description: "Random jitter in ms (kind=cron)" }), + ), + }, + { additionalProperties: true }, + ), + ), + sessionTarget: Type.Optional(Type.String({ description: "Session target" })), + wakeMode: optionalStringEnum(CRON_WAKE_MODES), + payload: Type.Optional( + cronPayloadObjectSchema({ + toolsAllow: nullableStringArraySchema("Allowed tool ids, or null to clear"), + }), + ), + delivery: Type.Optional( + Type.Object( + { + mode: optionalStringEnum(CRON_DELIVERY_MODES, { description: "Delivery mode" }), + channel: Type.Optional(Type.String({ description: "Delivery channel" })), + to: Type.Optional(Type.String({ description: "Delivery target" })), + bestEffort: Type.Optional(Type.Boolean()), + accountId: Type.Optional(Type.String({ description: "Account target for delivery" })), + failureDestination: Type.Optional( + Type.Object( + { + channel: Type.Optional(Type.String()), + to: Type.Optional(Type.String()), + accountId: Type.Optional(Type.String()), + mode: optionalStringEnum(["announce", "webhook"] as const), + }, + { additionalProperties: true }, + ), + ), + }, + { additionalProperties: true }, + ), + ), + description: Type.Optional(Type.String()), + enabled: Type.Optional(Type.Boolean()), + deleteAfterRun: Type.Optional(Type.Boolean()), + agentId: nullableStringSchema("Agent id, or null to clear it"), + sessionKey: nullableStringSchema("Explicit session key, or null to clear it"), + failureAlert: CronFailureAlertSchema, + }, + { additionalProperties: true }, + ), +); + // Flattened schema: runtime validates per-action requirements. -const CronToolSchema = Type.Object( +export const CronToolSchema = Type.Object( { action: stringEnum(CRON_ACTIONS), gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), includeDisabled: Type.Optional(Type.Boolean()), - job: Type.Optional(Type.Object({}, { additionalProperties: true })), + job: CronJobObjectSchema, jobId: Type.Optional(Type.String()), id: Type.Optional(Type.String()), - patch: Type.Optional(Type.Object({}, { additionalProperties: true })), + patch: CronPatchObjectSchema, text: Type.Optional(Type.String()), mode: optionalStringEnum(CRON_WAKE_MODES), runMode: optionalStringEnum(CRON_RUN_MODES), @@ -316,29 +535,10 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con params.job !== null && Object.keys(params.job as Record).length === 0) ) { - const JOB_KEYS: ReadonlySet = new Set([ - "name", - "schedule", - "sessionTarget", - "wakeMode", - "payload", - "delivery", - "enabled", - "description", - "deleteAfterRun", - "agentId", - "sessionKey", - "message", - "text", - "model", - "thinking", - "timeoutSeconds", - "allowUnsafeExternalContent", - ]); const synthetic: Record = {}; let found = false; for (const key of Object.keys(params)) { - if (JOB_KEYS.has(key) && params[key] !== undefined) { + if (CRON_RECOVERABLE_OBJECT_KEYS.has(key) && params[key] !== undefined) { synthetic[key] = params[key]; found = true; } @@ -457,37 +657,24 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con } // Flat-params recovery for patch + let recoveredFlatPatch = false; if ( !params.patch || (typeof params.patch === "object" && params.patch !== null && Object.keys(params.patch as Record).length === 0) ) { - const PATCH_KEYS: ReadonlySet = new Set([ - "name", - "schedule", - "payload", - "delivery", - "enabled", - "description", - "deleteAfterRun", - "agentId", - "sessionKey", - "sessionTarget", - "wakeMode", - "failureAlert", - "allowUnsafeExternalContent", - ]); const synthetic: Record = {}; let found = false; for (const key of Object.keys(params)) { - if (PATCH_KEYS.has(key) && params[key] !== undefined) { + if (CRON_RECOVERABLE_OBJECT_KEYS.has(key) && params[key] !== undefined) { synthetic[key] = params[key]; found = true; } } if (found) { params.patch = synthetic; + recoveredFlatPatch = true; } } @@ -495,6 +682,14 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con throw new Error("patch required"); } const patch = normalizeCronJobPatch(params.patch) ?? params.patch; + if ( + recoveredFlatPatch && + typeof patch === "object" && + patch !== null && + Object.keys(patch as Record).length === 0 + ) { + throw new Error("patch required"); + } return jsonResult( await callGateway("cron.update", gatewayOpts, { id, diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index c400ed081a8..23255762dc2 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { validateCronAddParams, validateCronUpdateParams } from "../gateway/protocol/index.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "./normalize.js"; import { DEFAULT_TOP_OF_HOUR_STAGGER_MS } from "./stagger.js"; @@ -343,6 +344,27 @@ describe("normalizeCronJobCreate", () => { expect(delivery.to).toBe("https://example.invalid/cron"); }); + it("does not default explicit mode-less delivery objects to announce", () => { + const normalized = normalizeCronJobCreate({ + name: "implicit announce", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "hello" }, + delivery: { + channel: "telegram", + to: "123", + }, + }) as unknown as Record; + + const delivery = normalized.delivery as Record; + expect(delivery.mode).toBeUndefined(); + expect(delivery.channel).toBe("telegram"); + expect(delivery.to).toBe("123"); + expect(validateCronAddParams(normalized)).toBe(false); + }); + it("defaults isolated agentTurn delivery to announce", () => { const normalized = normalizeIsolatedAgentTurnCreateJob({ name: "default-announce", @@ -391,6 +413,7 @@ describe("normalizeCronJobCreate", () => { model: " openrouter/deepseek/deepseek-r1 ", thinking: " high ", timeoutSeconds: 45, + toolsAllow: [" exec ", " read "], allowUnsafeExternalContent: true, }) as unknown as Record; @@ -398,7 +421,9 @@ describe("normalizeCronJobCreate", () => { expect(payload.model).toBe("openrouter/deepseek/deepseek-r1"); expect(payload.thinking).toBe("high"); expect(payload.timeoutSeconds).toBe(45); + expect(payload.toolsAllow).toEqual(["exec", "read"]); expect(payload.allowUnsafeExternalContent).toBe(true); + expect(validateCronAddParams(normalized)).toBe(true); }); it("preserves timeoutSeconds=0 for no-timeout agentTurn payloads", () => { @@ -413,6 +438,100 @@ describe("normalizeCronJobCreate", () => { expect(payload.timeoutSeconds).toBe(0); }); + it("preserves empty toolsAllow lists for create jobs", () => { + const normalized = normalizeCronJobCreate({ + name: "empty-tools", + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { + kind: "agentTurn", + message: "hello", + toolsAllow: [], + }, + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload.toolsAllow).toEqual([]); + expect(validateCronAddParams(normalized)).toBe(true); + }); + + it("prunes agentTurn-only payload fields from systemEvent create jobs", () => { + const normalized = normalizeCronJobCreate({ + name: "system-event-prune", + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { + kind: "systemEvent", + text: "hello", + model: "openai/gpt-5", + fallbacks: ["openai/gpt-4.1-mini"], + thinking: "high", + timeoutSeconds: 45, + lightContext: true, + toolsAllow: ["exec"], + allowUnsafeExternalContent: true, + }, + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload).toEqual({ kind: "systemEvent", text: "hello" }); + expect(validateCronAddParams(normalized)).toBe(true); + }); + + it("prunes schedule fields that do not belong to at schedules for create jobs", () => { + const normalized = normalizeCronJobCreate({ + name: "at-prune", + schedule: { + kind: "at", + at: "2026-01-12T18:00:00Z", + expr: "* * * * *", + everyMs: 60_000, + anchorMs: 123, + tz: "UTC", + staggerMs: 30_000, + }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { + kind: "systemEvent", + text: "hi", + }, + }) as unknown as Record; + + const schedule = normalized.schedule as Record; + expect(schedule).toEqual({ + kind: "at", + at: new Date("2026-01-12T18:00:00Z").toISOString(), + }); + expect(validateCronAddParams(normalized)).toBe(true); + }); + + it("prunes staggerMs from every schedules for create jobs", () => { + const normalized = normalizeCronJobCreate({ + name: "every-prune", + schedule: { + kind: "every", + everyMs: 60_000, + staggerMs: 30_000, + }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { + kind: "systemEvent", + text: "hi", + }, + }) as unknown as Record; + + const schedule = normalized.schedule as Record; + expect(schedule).toEqual({ + kind: "every", + everyMs: 60_000, + }); + expect(validateCronAddParams(normalized)).toBe(true); + }); + it("coerces sessionTarget and wakeMode casing", () => { const normalized = normalizeCronJobCreate({ name: "casing", @@ -477,6 +596,16 @@ describe("normalizeCronJobCreate", () => { }); describe("normalizeCronJobPatch", () => { + it("infers agentTurn payloads from top-level model-only patch hints", () => { + const normalized = normalizeCronJobPatch({ + model: "openrouter/deepseek/deepseek-r1", + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload.kind).toBe("agentTurn"); + expect(payload.model).toBe("openrouter/deepseek/deepseek-r1"); + }); + it("infers agentTurn kind for model-only payload patches", () => { const normalized = normalizeCronJobPatch({ payload: { @@ -489,6 +618,127 @@ describe("normalizeCronJobPatch", () => { expect(payload.model).toBe("anthropic/claude-sonnet-4-5"); }); + it("infers agentTurn kind for lightContext-only payload patches", () => { + const normalized = normalizeCronJobPatch({ + payload: { + lightContext: true, + }, + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload.kind).toBe("agentTurn"); + expect(payload.lightContext).toBe(true); + }); + + it("maps top-level fallback lists into agentTurn payload patches", () => { + const normalized = normalizeCronJobPatch({ + fallbacks: [" openrouter/gpt-4.1-mini ", "anthropic/claude-haiku-3-5"], + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload.kind).toBe("agentTurn"); + expect(payload.fallbacks).toEqual(["openrouter/gpt-4.1-mini", "anthropic/claude-haiku-3-5"]); + }); + + it("maps top-level toolsAllow lists into agentTurn payload patches", () => { + const normalized = normalizeCronJobPatch({ + toolsAllow: [" exec ", " read "], + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload.kind).toBe("agentTurn"); + expect(payload.toolsAllow).toEqual(["exec", "read"]); + expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true); + }); + + it("preserves empty fallback lists so patches can disable fallbacks", () => { + const normalized = normalizeCronJobPatch({ + payload: { + fallbacks: [], + }, + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload.kind).toBe("agentTurn"); + expect(payload.fallbacks).toEqual([]); + }); + + it("preserves empty toolsAllow lists so patches can disable all tools", () => { + const normalized = normalizeCronJobPatch({ + payload: { + toolsAllow: [], + }, + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload.kind).toBe("agentTurn"); + expect(payload.toolsAllow).toEqual([]); + expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true); + }); + + it("infers agentTurn kind for fallback-only payload patches", () => { + const normalized = normalizeCronJobPatch({ + payload: { + fallbacks: [" openrouter/gpt-4.1-mini ", "anthropic/claude-haiku-3-5"], + }, + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload.kind).toBe("agentTurn"); + expect(payload.fallbacks).toEqual(["openrouter/gpt-4.1-mini", "anthropic/claude-haiku-3-5"]); + }); + + it("does not infer agentTurn kind for malformed fallback-only payload patches", () => { + const normalized = normalizeCronJobPatch({ + payload: { + fallbacks: [123], + }, + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload.kind).toBeUndefined(); + expect(payload.fallbacks).toBeUndefined(); + expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(false); + }); + + it("infers agentTurn kind for toolsAllow-only payload patches", () => { + const normalized = normalizeCronJobPatch({ + payload: { + toolsAllow: [" exec ", " read "], + }, + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload.kind).toBe("agentTurn"); + expect(payload.toolsAllow).toEqual(["exec", "read"]); + expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true); + }); + + it("does not infer agentTurn kind for malformed toolsAllow-only payload patches", () => { + const normalized = normalizeCronJobPatch({ + payload: { + toolsAllow: [123], + }, + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload.kind).toBeUndefined(); + expect(payload.toolsAllow).toBeUndefined(); + expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(false); + }); + + it("preserves null toolsAllow so patches can clear the allow-list", () => { + const normalized = normalizeCronJobPatch({ + payload: { + toolsAllow: null, + }, + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload.kind).toBe("agentTurn"); + expect(payload.toolsAllow).toBeNull(); + expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true); + }); it("does not infer agentTurn kind for delivery-only legacy hints", () => { const normalized = normalizeCronJobPatch({ payload: { @@ -534,4 +784,62 @@ describe("normalizeCronJobPatch", () => { expect(normalized.delivery).toBeUndefined(); expect((normalized.payload as Record).threadId).toBeUndefined(); }); + + it("prunes agentTurn-only payload fields from systemEvent patch payloads", () => { + const normalized = normalizeCronJobPatch({ + payload: { + kind: "systemEvent", + text: "hi", + model: "openai/gpt-5", + fallbacks: ["openai/gpt-4.1-mini"], + thinking: "high", + timeoutSeconds: 15, + lightContext: true, + toolsAllow: ["exec"], + allowUnsafeExternalContent: true, + }, + }) as unknown as Record; + + const payload = normalized.payload as Record; + expect(payload).toEqual({ kind: "systemEvent", text: "hi" }); + expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true); + }); + + it("prunes schedule fields that do not belong to at schedules for patches", () => { + const normalized = normalizeCronJobPatch({ + schedule: { + kind: "at", + at: "2026-01-12T18:00:00Z", + expr: "* * * * *", + everyMs: 60_000, + anchorMs: 123, + tz: "UTC", + staggerMs: 30_000, + }, + }) as unknown as Record; + + const schedule = normalized.schedule as Record; + expect(schedule).toEqual({ + kind: "at", + at: new Date("2026-01-12T18:00:00Z").toISOString(), + }); + expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true); + }); + + it("prunes staggerMs from every schedules for patches", () => { + const normalized = normalizeCronJobPatch({ + schedule: { + kind: "every", + everyMs: 60_000, + staggerMs: 30_000, + }, + }) as unknown as Record; + + const schedule = normalized.schedule as Record; + expect(schedule).toEqual({ + kind: "every", + everyMs: 60_000, + }); + expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true); + }); }); diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 6bb1d26952a..49d32224986 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -23,6 +23,41 @@ const DEFAULT_OPTIONS: NormalizeOptions = { applyDefaults: false, }; +function hasTrimmedStringValue(value: unknown) { + return parseOptionalField(TrimmedNonEmptyStringFieldSchema, value) !== undefined; +} + +function hasAgentTurnPayloadHint(payload: UnknownRecord) { + return ( + hasTrimmedStringValue(payload.model) || + normalizeTrimmedStringArray(payload.fallbacks) !== undefined || + normalizeTrimmedStringArray(payload.toolsAllow, { allowNull: true }) !== undefined || + hasTrimmedStringValue(payload.thinking) || + typeof payload.timeoutSeconds === "number" || + typeof payload.lightContext === "boolean" || + typeof payload.allowUnsafeExternalContent === "boolean" + ); +} + +function normalizeTrimmedStringArray( + value: unknown, + options?: { allowNull?: boolean }, +): string[] | null | undefined { + if (Array.isArray(value)) { + const normalized = value + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()); + if (normalized.length === 0 && value.length > 0) { + return undefined; + } + return normalized; + } + if (options?.allowNull && value === null) { + return null; + } + return undefined; +} + function coerceSchedule(schedule: UnknownRecord) { const next: UnknownRecord = { ...schedule }; const rawKind = typeof schedule.kind === "string" ? schedule.kind.trim().toLowerCase() : ""; @@ -83,6 +118,23 @@ function coerceSchedule(schedule: UnknownRecord) { delete next.staggerMs; } + if (next.kind === "at") { + delete next.everyMs; + delete next.anchorMs; + delete next.expr; + delete next.tz; + delete next.staggerMs; + } else if (next.kind === "every") { + delete next.at; + delete next.expr; + delete next.tz; + delete next.staggerMs; + } else if (next.kind === "cron") { + delete next.at; + delete next.everyMs; + delete next.anchorMs; + } + return next; } @@ -99,16 +151,11 @@ function coercePayload(payload: UnknownRecord) { if (!next.kind) { const hasMessage = typeof next.message === "string" && next.message.trim().length > 0; const hasText = typeof next.text === "string" && next.text.trim().length > 0; - const hasAgentTurnHint = - typeof next.model === "string" || - typeof next.thinking === "string" || - typeof next.timeoutSeconds === "number" || - typeof next.allowUnsafeExternalContent === "boolean"; if (hasMessage) { next.kind = "agentTurn"; } else if (hasText) { next.kind = "systemEvent"; - } else if (hasAgentTurnHint) { + } else if (hasAgentTurnPayloadHint(next)) { // Accept partial agentTurn payload patches that only tweak agent-turn-only fields. next.kind = "agentTurn"; } @@ -149,26 +196,39 @@ function coercePayload(payload: UnknownRecord) { delete next.timeoutSeconds; } } + if ("fallbacks" in next) { + const fallbacks = normalizeTrimmedStringArray(next.fallbacks); + if (fallbacks !== undefined) { + next.fallbacks = fallbacks; + } else { + delete next.fallbacks; + } + } + if ("toolsAllow" in next) { + const toolsAllow = normalizeTrimmedStringArray(next.toolsAllow, { allowNull: true }); + if (toolsAllow !== undefined) { + next.toolsAllow = toolsAllow; + } else { + delete next.toolsAllow; + } + } if ( "allowUnsafeExternalContent" in next && typeof next.allowUnsafeExternalContent !== "boolean" ) { delete next.allowUnsafeExternalContent; } - if ("toolsAllow" in next) { - if (Array.isArray(next.toolsAllow)) { - next.toolsAllow = (next.toolsAllow as unknown[]) - .filter((t): t is string => typeof t === "string" && t.trim().length > 0) - .map((t) => t.trim()); - if ((next.toolsAllow as string[]).length === 0) { - delete next.toolsAllow; - } - } else if (next.toolsAllow === null) { - // Explicit null means "clear the allow-list" (edit --clear-tools) - next.toolsAllow = null; - } else { - delete next.toolsAllow; - } + if (next.kind === "systemEvent") { + delete next.message; + delete next.model; + delete next.fallbacks; + delete next.thinking; + delete next.timeoutSeconds; + delete next.lightContext; + delete next.allowUnsafeExternalContent; + delete next.toolsAllow; + } else if (next.kind === "agentTurn") { + delete next.text; } if ("deliver" in next) { delete next.deliver; @@ -222,6 +282,24 @@ function coerceDelivery(delivery: UnknownRecord) { return next; } +function inferTopLevelPayload(next: UnknownRecord) { + const message = typeof next.message === "string" ? next.message.trim() : ""; + if (message) { + return { kind: "agentTurn", message } satisfies UnknownRecord; + } + + const text = typeof next.text === "string" ? next.text.trim() : ""; + if (text) { + return { kind: "systemEvent", text } satisfies UnknownRecord; + } + + if (hasAgentTurnPayloadHint(next)) { + return { kind: "agentTurn" } satisfies UnknownRecord; + } + + return null; +} + function unwrapJob(raw: UnknownRecord) { if (isRecord(raw.data)) { return raw.data; @@ -278,6 +356,21 @@ function copyTopLevelAgentTurnFields(next: UnknownRecord, payload: UnknownRecord if (typeof payload.timeoutSeconds !== "number" && typeof next.timeoutSeconds === "number") { payload.timeoutSeconds = next.timeoutSeconds; } + if (!Array.isArray(payload.fallbacks) && Array.isArray(next.fallbacks)) { + const fallbacks = normalizeTrimmedStringArray(next.fallbacks); + if (fallbacks !== undefined) { + payload.fallbacks = fallbacks; + } + } + if (!("toolsAllow" in payload) || payload.toolsAllow === undefined) { + const toolsAllow = normalizeTrimmedStringArray(next.toolsAllow, { allowNull: true }); + if (toolsAllow !== undefined) { + payload.toolsAllow = toolsAllow; + } + } + if (typeof payload.lightContext !== "boolean" && typeof next.lightContext === "boolean") { + payload.lightContext = next.lightContext; + } if ( typeof payload.allowUnsafeExternalContent !== "boolean" && typeof next.allowUnsafeExternalContent === "boolean" @@ -290,12 +383,16 @@ function stripLegacyTopLevelFields(next: UnknownRecord) { delete next.model; delete next.thinking; delete next.timeoutSeconds; + delete next.fallbacks; + delete next.lightContext; + delete next.toolsAllow; delete next.allowUnsafeExternalContent; delete next.message; delete next.text; delete next.deliver; delete next.channel; delete next.to; + delete next.toolsAllow; delete next.threadId; delete next.bestEffortDeliver; delete next.provider; @@ -377,12 +474,9 @@ export function normalizeCronJobInput( } if (!("payload" in next) || !isRecord(next.payload)) { - const message = typeof next.message === "string" ? next.message.trim() : ""; - const text = typeof next.text === "string" ? next.text.trim() : ""; - if (message) { - next.payload = { kind: "agentTurn", message }; - } else if (text) { - next.payload = { kind: "systemEvent", text }; + const inferredPayload = inferTopLevelPayload(next); + if (inferredPayload) { + next.payload = inferredPayload; } } diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index c310cc34307..68770bd8908 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -169,6 +169,81 @@ describe("applyJobPatch", () => { } }); + it("persists agentTurn payload.fallbacks updates when editing existing jobs", () => { + const job = createIsolatedAgentTurnJob("job-fallbacks", { + mode: "announce", + channel: "telegram", + }); + job.payload = { + kind: "agentTurn", + message: "do it", + fallbacks: ["openrouter/gpt-4.1-mini"], + }; + + applyJobPatch(job, { + payload: { + kind: "agentTurn", + message: "do it", + fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"], + }, + }); + + expect(job.payload.kind).toBe("agentTurn"); + if (job.payload.kind === "agentTurn") { + expect(job.payload.fallbacks).toEqual(["anthropic/claude-haiku-3-5", "openai/gpt-5"]); + } + }); + + it("persists agentTurn payload.toolsAllow updates when editing existing jobs", () => { + const job = createIsolatedAgentTurnJob("job-tools", { + mode: "announce", + channel: "telegram", + }); + job.payload = { + kind: "agentTurn", + message: "do it", + toolsAllow: ["exec"], + }; + + applyJobPatch(job, { + payload: { + kind: "agentTurn", + message: "do it", + toolsAllow: ["read", "write"], + }, + }); + + expect(job.payload.kind).toBe("agentTurn"); + if (job.payload.kind === "agentTurn") { + expect(job.payload.toolsAllow).toEqual(["read", "write"]); + } + }); + + it("clears agentTurn payload.toolsAllow when patch requests null", () => { + const job = createIsolatedAgentTurnJob("job-tools-clear", { + mode: "announce", + channel: "telegram", + }); + job.payload = { + kind: "agentTurn", + message: "do it", + toolsAllow: ["exec", "read"], + }; + + applyJobPatch(job, { + payload: { + kind: "agentTurn", + message: "do it", + toolsAllow: null, + }, + }); + + expect(job.payload.kind).toBe("agentTurn"); + if (job.payload.kind === "agentTurn") { + expect(job.payload.toolsAllow).toBeUndefined(); + } + }); + it("applies payload.lightContext when replacing payload kind via patch", () => { const job = createIsolatedAgentTurnJob("job-light-context-switch", { mode: "announce", @@ -191,6 +266,50 @@ describe("applyJobPatch", () => { } }); + it("carries payload.fallbacks when replacing payload kind via patch", () => { + const job = createIsolatedAgentTurnJob("job-fallbacks-switch", { + mode: "announce", + channel: "telegram", + }); + job.payload = { kind: "systemEvent", text: "ping" }; + + applyJobPatch(job, { + payload: { + kind: "agentTurn", + message: "do it", + fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"], + }, + }); + + const payload = job.payload as CronJob["payload"]; + expect(payload.kind).toBe("agentTurn"); + if (payload.kind === "agentTurn") { + expect(payload.fallbacks).toEqual(["anthropic/claude-haiku-3-5", "openai/gpt-5"]); + } + }); + + it("carries payload.toolsAllow when replacing payload kind via patch", () => { + const job = createIsolatedAgentTurnJob("job-tools-switch", { + mode: "announce", + channel: "telegram", + }); + job.payload = { kind: "systemEvent", text: "ping" }; + + applyJobPatch(job, { + payload: { + kind: "agentTurn", + message: "do it", + toolsAllow: ["exec", "read"], + }, + }); + + const payload = job.payload as CronJob["payload"]; + expect(payload.kind).toBe("agentTurn"); + if (payload.kind === "agentTurn") { + expect(payload.toolsAllow).toEqual(["exec", "read"]); + } + }); + it.each([ { name: "no delivery update", patch: { enabled: true } satisfies CronJobPatch }, { diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 03ac0b3039b..7dd9b5d3251 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -687,6 +687,14 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP if (typeof patch.model === "string") { next.model = patch.model; } + if (Array.isArray(patch.fallbacks)) { + next.fallbacks = patch.fallbacks; + } + if (Array.isArray(patch.toolsAllow)) { + next.toolsAllow = patch.toolsAllow; + } else if (patch.toolsAllow === null) { + delete next.toolsAllow; + } if (typeof patch.thinking === "string") { next.thinking = patch.thinking; } @@ -718,6 +726,8 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload { kind: "agentTurn", message: patch.message, model: patch.model, + fallbacks: patch.fallbacks, + toolsAllow: Array.isArray(patch.toolsAllow) ? patch.toolsAllow : undefined, thinking: patch.thinking, timeoutSeconds: patch.timeoutSeconds, lightContext: patch.lightContext, diff --git a/src/cron/types.ts b/src/cron/types.ts index 940a76ddb9c..c10c59698e2 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -108,7 +108,9 @@ type CronAgentTurnPayload = { type CronAgentTurnPayloadPatch = { kind: "agentTurn"; -} & Partial; +} & Partial> & { + toolsAllow?: string[] | null; + }; export type CronJobState = { nextRunAtMs?: number; runningAtMs?: number; diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index c3871958ff4..da3c7110b88 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -1,7 +1,7 @@ import { Type, type TSchema } from "@sinclair/typebox"; import { NonEmptyString } from "./primitives.js"; -function cronAgentTurnPayloadSchema(params: { message: TSchema }) { +function cronAgentTurnPayloadSchema(params: { message: TSchema; toolsAllow: TSchema }) { return Type.Object( { kind: Type.Literal("agentTurn"), @@ -12,6 +12,7 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema }) { timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), allowUnsafeExternalContent: Type.Optional(Type.Boolean()), lightContext: Type.Optional(Type.Boolean()), + toolsAllow: Type.Optional(params.toolsAllow), }, { additionalProperties: false }, ); @@ -134,7 +135,10 @@ export const CronPayloadSchema = Type.Union([ }, { additionalProperties: false }, ), - cronAgentTurnPayloadSchema({ message: NonEmptyString }), + cronAgentTurnPayloadSchema({ + message: NonEmptyString, + toolsAllow: Type.Array(Type.String()), + }), ]); export const CronPayloadPatchSchema = Type.Union([ @@ -145,7 +149,10 @@ export const CronPayloadPatchSchema = Type.Union([ }, { additionalProperties: false }, ), - cronAgentTurnPayloadSchema({ message: Type.Optional(NonEmptyString) }), + cronAgentTurnPayloadSchema({ + message: Type.Optional(NonEmptyString), + toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]), + }), ]); export const CronFailureAlertSchema = Type.Object(