From 87af1081407fe32f8e17654b240cf66c6cbc30fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=87=E5=AE=99=E7=86=8AYzx?= <53250620+849261680@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:54:35 +0800 Subject: [PATCH] fix(cron): clear payload model overrides --- docs/automation/cron-jobs.md | 1 + .../src/cron-validators.test.ts | 24 ++++++++ packages/gateway-protocol/src/schema/cron.ts | 10 ++- src/agents/tools/cron-tool-canonicalize.ts | 1 + src/agents/tools/cron-tool.schema.test.ts | 2 + src/agents/tools/cron-tool.test.ts | 32 ++++++++++ src/agents/tools/cron-tool.ts | 6 +- src/cron/normalize.test.ts | 11 ++++ src/cron/normalize.ts | 12 ++-- src/cron/service.jobs.test.ts | 44 +++++++++++++ src/cron/service/jobs.ts | 4 +- src/cron/types.ts | 3 +- src/gateway/server.cron.test.ts | 61 +++++++++++++++++++ 13 files changed, 201 insertions(+), 10 deletions(-) diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 85dd85b0f40b..9497d4887150 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -470,6 +470,7 @@ Model override note: - `openclaw cron add|edit --model ...` changes the job's selected model. - If the model is allowed, that exact provider/model reaches the isolated agent run. - If it is not allowed or cannot be resolved, cron fails the run with an explicit validation error. +- API `cron.update` payload patches can set `model: null` to clear a stored job model override. - Configured fallback chains still apply because cron `--model` is a job primary, not a session `/model` override. - Payload `fallbacks` replaces configured fallbacks for that job; `fallbacks: []` disables fallback and makes the run strict. - A plain `--model` with no explicit or configured fallback list does not fall through to the agent primary as a silent extra retry target. diff --git a/packages/gateway-protocol/src/cron-validators.test.ts b/packages/gateway-protocol/src/cron-validators.test.ts index 2fbf27382cab..b0856f6b9c08 100644 --- a/packages/gateway-protocol/src/cron-validators.test.ts +++ b/packages/gateway-protocol/src/cron-validators.test.ts @@ -94,6 +94,30 @@ describe("cron protocol validators", () => { expect(validateCronUpdateParams({ jobId: "job-2", patch: { enabled: true } })).toBe(true); }); + it("accepts nullable model clears only on update payload patches", () => { + expect( + validateCronUpdateParams({ + id: "job-1", + patch: { + payload: { + kind: "agentTurn", + model: null, + }, + }, + }), + ).toBe(true); + expect( + validateCronAddParams({ + ...minimalAddParams, + payload: { + kind: "agentTurn", + message: "tick", + model: null, + }, + }), + ).toBe(false); + }); + it("accepts get params for id and jobId selectors", () => { expect(validateCronGetParams({ id: "job-1" })).toBe(true); expect(validateCronGetParams({ jobId: "job-2" })).toBe(true); diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts index 86b2741ec87d..590515296e65 100644 --- a/packages/gateway-protocol/src/schema/cron.ts +++ b/packages/gateway-protocol/src/schema/cron.ts @@ -10,12 +10,16 @@ import { NonEmptyString } from "./primitives.js"; */ /** Builds create/patch payload variants while preserving per-call field optionality. */ -function cronAgentTurnPayloadSchema(params: { message: TSchema; toolsAllow: TSchema }) { +function cronAgentTurnPayloadSchema(params: { + message: TSchema; + model: TSchema; + toolsAllow: TSchema; +}) { return Type.Object( { kind: Type.Literal("agentTurn"), message: params.message, - model: Type.Optional(Type.String()), + model: Type.Optional(params.model), fallbacks: Type.Optional(Type.Array(Type.String())), thinking: Type.Optional(Type.String()), timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), @@ -227,6 +231,7 @@ export const CronPayloadSchema = Type.Union([ ), cronAgentTurnPayloadSchema({ message: NonEmptyString, + model: Type.String(), toolsAllow: Type.Array(Type.String()), }), cronCommandPayloadSchema({ @@ -245,6 +250,7 @@ export const CronPayloadPatchSchema = Type.Union([ ), cronAgentTurnPayloadSchema({ message: Type.Optional(NonEmptyString), + model: Type.Union([Type.String(), Type.Null()]), toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]), }), cronCommandPayloadSchema({ diff --git a/src/agents/tools/cron-tool-canonicalize.ts b/src/agents/tools/cron-tool-canonicalize.ts index d06a026e56e4..be0b7b6506ff 100644 --- a/src/agents/tools/cron-tool-canonicalize.ts +++ b/src/agents/tools/cron-tool-canonicalize.ts @@ -224,6 +224,7 @@ function canonicalizeCronToolPayload(value: Record): void { const hasAgentTurnSignal = isNonEmptyString(payload.message) || isNonEmptyString(payload.model) || + payload.model === null || isNonEmptyString(payload.thinking) || typeof payload.timeoutSeconds === "number" || typeof payload.lightContext === "boolean" || diff --git a/src/agents/tools/cron-tool.schema.test.ts b/src/agents/tools/cron-tool.schema.test.ts index 6c4062be0991..3d9ade863ac4 100644 --- a/src/agents/tools/cron-tool.schema.test.ts +++ b/src/agents/tools/cron-tool.schema.test.ts @@ -250,6 +250,8 @@ describe("CronToolSchema", () => { // unions so OpenAPI 3.0 subset validators accept them. expect(patchProps?.payload?.properties?.toolsAllow?.type).toBe("array"); expect(patchProps?.payload?.properties?.toolsAllow?.description).toMatch(/null to clear/i); + expect(patchProps?.payload?.properties?.model?.type).toBe("string"); + expect(patchProps?.payload?.properties?.model?.description).toMatch(/null to clear/i); }); // Regression guard: ensure no OpenAPI 3.0 incompatible keywords leak into the diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 883daf9d4b37..4fda5a738f49 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -1769,4 +1769,36 @@ describe("cron tool", () => { toolsAllow: null, }); }); + + it("preserves null model payload patches on update", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const tool = createTestCronTool(); + await tool.execute("call-update-clear-model", { + action: "update", + id: "job-9", + patch: { + payload: { + model: null, + }, + }, + }); + + const params = expectSingleGatewayCallMethod("cron.update") as + | { + id?: string; + patch?: { + payload?: { + kind?: string; + model?: string | null; + }; + }; + } + | undefined; + expect(params?.id).toBe("job-9"); + expect(params?.patch?.payload).toEqual({ + kind: "agentTurn", + model: null, + }); + }); }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 0243cf68b512..4c6372a9e04d 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -97,13 +97,13 @@ function failureDestinationModeSchema(params: { nullableClears: boolean }) { return Type.Optional(Type.Union(variants)); } -function cronPayloadObjectSchema(params: { toolsAllow: TSchema }) { +function cronPayloadObjectSchema(params: { model: TSchema; toolsAllow: TSchema }) { return Type.Object( { kind: optionalStringEnum(CRON_PAYLOAD_KINDS, { description: "Payload kind" }), text: Type.Optional(Type.String({ description: "systemEvent text" })), message: Type.Optional(Type.String({ description: "agentTurn prompt" })), - model: Type.Optional(Type.String({ description: "Model override" })), + model: params.model, thinking: Type.Optional(Type.String({ description: "Thinking override" })), timeoutSeconds: optionalFiniteNumberSchema({ minimum: 0 }), lightContext: Type.Optional(Type.Boolean()), @@ -147,6 +147,7 @@ function createCronScheduleSchema(): TSchema { function createCronPayloadSchema(): TSchema { return Type.Optional( cronPayloadObjectSchema({ + model: Type.Optional(Type.String({ description: "Model override" })), toolsAllow: Type.Optional(Type.Array(Type.String(), { description: "Allowed tools" })), }), ); @@ -273,6 +274,7 @@ function createCronPatchObjectSchema(): TSchema { wakeMode: optionalStringEnum(CRON_WAKE_MODES), payload: Type.Optional( cronPayloadObjectSchema({ + model: nullableStringSchema("Model override, or null to clear"), toolsAllow: nullableStringArraySchema("Allowed tool ids, or null to clear"), }), ), diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index b2a427d8fefe..d1380430e28d 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -142,6 +142,17 @@ describe("normalizeCronJobCreate", () => { expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" }); }); + it("preserves explicit null model clear in payload patches", () => { + const normalized = normalizeCronJobPatch({ + payload: { + kind: "agentTurn", + model: null, + }, + }) as unknown as Record; + + expect(normalized.payload?.model).toBeNull(); + }); + it("coerces ISO schedule.at to normalized ISO (UTC)", () => { expectNormalizedAtSchedule({ kind: "at", at: "2026-01-12T18:00:00" }); }); diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index a5daa9cccc5d..d100bd665016 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -170,11 +170,15 @@ function coercePayload(payload: UnknownRecord) { } } if ("model" in next) { - const model = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.model); - if (model !== undefined) { - next.model = model; + if (next.model === null) { + next.model = null; } else { - delete next.model; + const model = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.model); + if (model !== undefined) { + next.model = model; + } else { + delete next.model; + } } } if ("thinking" in next) { diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 622044062a83..7766444544a0 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -369,6 +369,50 @@ describe("applyJobPatch", () => { } }); + it("clears agentTurn payload.model when patch requests null", () => { + const job = createIsolatedAgentTurnJob("job-model-clear", { + mode: "announce", + channel: "telegram", + }); + job.payload = { + kind: "agentTurn", + message: "do it", + model: "openai/gpt-5.5", + }; + + applyJobPatch(job, { + payload: { + kind: "agentTurn", + model: null, + }, + }); + + expect(job.payload.kind).toBe("agentTurn"); + if (job.payload.kind === "agentTurn") { + expect(job.payload.message).toBe("do it"); + expect(job.payload.model).toBeUndefined(); + } + }); + + it("omits null model when patch builds a replacement agentTurn payload", () => { + const job = createMainSystemEventJob("job-model-replace", { mode: "none" }); + + applyJobPatch(job, { + sessionTarget: "isolated", + payload: { + kind: "agentTurn", + message: "do it", + model: null, + }, + }); + + expect(job.payload.kind).toBe("agentTurn"); + if (job.payload.kind === "agentTurn") { + expect(job.payload.message).toBe("do it"); + expect(job.payload.model).toBeUndefined(); + } + }); + it("applies payload.lightContext when replacing payload kind via patch", () => { const job = createIsolatedAgentTurnJob("job-light-context-switch", { mode: "announce", diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 97ab6ffdd126..8b4eccc52afb 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -923,6 +923,8 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP } if (typeof patch.model === "string") { next.model = patch.model; + } else if (patch.model === null) { + delete next.model; } if (Array.isArray(patch.fallbacks)) { next.fallbacks = patch.fallbacks; @@ -978,7 +980,7 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload { return { kind: "agentTurn", message: patch.message, - model: patch.model, + model: typeof patch.model === "string" ? patch.model : undefined, fallbacks: patch.fallbacks, toolsAllow: Array.isArray(patch.toolsAllow) ? patch.toolsAllow : undefined, thinking: patch.thinking, diff --git a/src/cron/types.ts b/src/cron/types.ts index 541bdefdc73b..6ecc80d582db 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -256,7 +256,8 @@ type CronAgentTurnPayload = { type CronAgentTurnPayloadPatch = { kind: "agentTurn"; -} & Partial> & { +} & Partial> & { + model?: string | null; toolsAllow?: string[] | null; }; diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index b4d352f6d3fb..003d64ee660f 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -721,6 +721,67 @@ describe("gateway server cron", () => { expect(modelOnlyPatched?.payload?.message).toBe("hello"); expect(modelOnlyPatched?.payload?.model).toBe("anthropic/claude-sonnet-4-6"); + const modelClearPatchRes = await directCronReq(cronState, "cron.update", { + id: mergeJobId, + patch: { + payload: { + kind: "agentTurn", + model: null, + }, + }, + }); + expect(modelClearPatchRes.ok).toBe(true); + const modelCleared = modelClearPatchRes.payload as + | { + payload?: { + kind?: unknown; + message?: unknown; + model?: unknown; + }; + } + | undefined; + expect(modelCleared?.payload?.kind).toBe("agentTurn"); + expect(modelCleared?.payload?.message).toBe("hello"); + expect(modelCleared?.payload?.model).toBeUndefined(); + + const replaceRes = await directCronReq(cronState, "cron.add", { + name: "replace payload", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + expect(replaceRes.ok).toBe(true); + const replaceJobIdValue = (replaceRes.payload as { id?: unknown } | null)?.id; + const replaceJobId = typeof replaceJobIdValue === "string" ? replaceJobIdValue : ""; + expect(replaceJobId.length > 0).toBe(true); + + const replacePatchRes = await directCronReq(cronState, "cron.update", { + id: replaceJobId, + patch: { + sessionTarget: "isolated", + payload: { + kind: "agentTurn", + message: "hello", + model: null, + }, + }, + }); + expect(replacePatchRes.ok).toBe(true); + const replaced = replacePatchRes.payload as + | { + payload?: { + kind?: unknown; + message?: unknown; + model?: unknown; + }; + } + | undefined; + expect(replaced?.payload?.kind).toBe("agentTurn"); + expect(replaced?.payload?.message).toBe("hello"); + expect(replaced?.payload?.model).toBeUndefined(); + const deliveryPatchRes = await directCronReq(cronState, "cron.update", { id: mergeJobId, patch: {