mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-08 07:41:08 +00:00
fix(cron-tool): add typed properties to job/patch schemas (#55043)
Merged via squash.
Prepared head SHA: 979bb0e8b7
Co-authored-by: brunolorente <127802443+brunolorente@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
@@ -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<string, unknown>;
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>).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<string, unknown>;
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
expect(schedule).toEqual({
|
||||
kind: "every",
|
||||
everyMs: 60_000,
|
||||
});
|
||||
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -108,7 +108,9 @@ type CronAgentTurnPayload = {
|
||||
|
||||
type CronAgentTurnPayloadPatch = {
|
||||
kind: "agentTurn";
|
||||
} & Partial<CronAgentTurnPayloadFields>;
|
||||
} & Partial<Omit<CronAgentTurnPayloadFields, "toolsAllow">> & {
|
||||
toolsAllow?: string[] | null;
|
||||
};
|
||||
export type CronJobState = {
|
||||
nextRunAtMs?: number;
|
||||
runningAtMs?: number;
|
||||
|
||||
Reference in New Issue
Block a user