fix(cron): clear payload model overrides

This commit is contained in:
宇宙熊Yzx
2026-06-08 20:54:35 +08:00
parent 439dcbde3b
commit 87af108140
13 changed files with 201 additions and 10 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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({

View File

@@ -224,6 +224,7 @@ function canonicalizeCronToolPayload(value: Record<string, unknown>): void {
const hasAgentTurnSignal =
isNonEmptyString(payload.message) ||
isNonEmptyString(payload.model) ||
payload.model === null ||
isNonEmptyString(payload.thinking) ||
typeof payload.timeoutSeconds === "number" ||
typeof payload.lightContext === "boolean" ||

View File

@@ -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

View File

@@ -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,
});
});
});

View File

@@ -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"),
}),
),

View File

@@ -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<string, { model?: unknown }>;
expect(normalized.payload?.model).toBeNull();
});
it("coerces ISO schedule.at to normalized ISO (UTC)", () => {
expectNormalizedAtSchedule({ kind: "at", at: "2026-01-12T18:00:00" });
});

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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,

View File

@@ -256,7 +256,8 @@ type CronAgentTurnPayload = {
type CronAgentTurnPayloadPatch = {
kind: "agentTurn";
} & Partial<Omit<CronAgentTurnPayloadFields, "toolsAllow">> & {
} & Partial<Omit<CronAgentTurnPayloadFields, "model" | "toolsAllow">> & {
model?: string | null;
toolsAllow?: string[] | null;
};

View File

@@ -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: {