mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-09 03:22:40 +00:00
fix(cron): clear payload model overrides
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user