mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 13:22:14 +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:
171
src/agents/tools/cron-tool.schema.test.ts
Normal file
171
src/agents/tools/cron-tool.schema.test.ts
Normal file
@@ -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<string, unknown>, path: string): string[] {
|
||||
let cursor: Record<string, unknown> | undefined = schema;
|
||||
for (const segment of path.split(".")) {
|
||||
const props = cursor?.["properties"] as Record<string, Record<string, unknown>> | undefined;
|
||||
cursor = props?.[segment];
|
||||
}
|
||||
const leaf = cursor?.["properties"] as Record<string, unknown> | undefined;
|
||||
return leaf ? Object.keys(leaf).toSorted() : [];
|
||||
}
|
||||
|
||||
function propertyAt(
|
||||
schema: Record<string, unknown>,
|
||||
path: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
let cursor: Record<string, unknown> | undefined = schema;
|
||||
for (const segment of path.split(".")) {
|
||||
const props = cursor?.["properties"] as Record<string, Record<string, unknown>> | 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<string, unknown>, "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<string, unknown>, "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<string, unknown>, "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<string, unknown>,
|
||||
"job.schedule.staggerMs",
|
||||
);
|
||||
const patchStagger = propertyAt(
|
||||
CronToolSchema as Record<string, unknown>,
|
||||
"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<string, unknown>, "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<string, unknown>, "job.payload")).toEqual(
|
||||
[
|
||||
"allowUnsafeExternalContent",
|
||||
"fallbacks",
|
||||
"kind",
|
||||
"lightContext",
|
||||
"message",
|
||||
"model",
|
||||
"text",
|
||||
"thinking",
|
||||
"toolsAllow",
|
||||
"timeoutSeconds",
|
||||
].toSorted(),
|
||||
);
|
||||
});
|
||||
|
||||
it("job.payload includes fallbacks", () => {
|
||||
expect(keysAt(CronToolSchema as Record<string, unknown>, "job.payload")).toContain("fallbacks");
|
||||
});
|
||||
|
||||
it("patch.payload exposes agentTurn fallback overrides", () => {
|
||||
expect(keysAt(CronToolSchema as Record<string, unknown>, "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<string, unknown>, "job.failureAlert")).toEqual(
|
||||
["accountId", "after", "channel", "cooldownMs", "mode", "to"].toSorted(),
|
||||
);
|
||||
});
|
||||
|
||||
it("job.failureAlert also allows boolean false", () => {
|
||||
const root = (CronToolSchema as Record<string, unknown>).properties as
|
||||
| Record<string, { properties?: Record<string, unknown>; type?: unknown }>
|
||||
| undefined;
|
||||
const jobProps = root?.job?.properties as
|
||||
| Record<string, { type?: unknown; not?: { const?: unknown } }>
|
||||
| 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<string, unknown>).properties as
|
||||
| Record<string, { properties?: Record<string, unknown> }>
|
||||
| undefined;
|
||||
const jobProps = root?.job?.properties as Record<string, { type?: unknown }> | 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<string, unknown>).properties as
|
||||
| Record<string, { properties?: Record<string, unknown> }>
|
||||
| undefined;
|
||||
const patchProps = root?.patch?.properties as
|
||||
| Record<string, { properties?: Record<string, { type?: unknown }> }>
|
||||
| undefined;
|
||||
|
||||
expect(patchProps?.payload?.properties?.toolsAllow?.type).toEqual(["array", "null"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> = 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<string | null>({
|
||||
type: ["string", "null"],
|
||||
description,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function nullableStringArraySchema(description: string) {
|
||||
return Type.Optional(
|
||||
Type.Unsafe<string[] | null>({
|
||||
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<Record<string, unknown> | 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:<id>"',
|
||||
}),
|
||||
),
|
||||
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<string, unknown>).length === 0)
|
||||
) {
|
||||
const JOB_KEYS: ReadonlySet<string> = new Set([
|
||||
"name",
|
||||
"schedule",
|
||||
"sessionTarget",
|
||||
"wakeMode",
|
||||
"payload",
|
||||
"delivery",
|
||||
"enabled",
|
||||
"description",
|
||||
"deleteAfterRun",
|
||||
"agentId",
|
||||
"sessionKey",
|
||||
"message",
|
||||
"text",
|
||||
"model",
|
||||
"thinking",
|
||||
"timeoutSeconds",
|
||||
"allowUnsafeExternalContent",
|
||||
]);
|
||||
const synthetic: Record<string, unknown> = {};
|
||||
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<string, unknown>).length === 0)
|
||||
) {
|
||||
const PATCH_KEYS: ReadonlySet<string> = new Set([
|
||||
"name",
|
||||
"schedule",
|
||||
"payload",
|
||||
"delivery",
|
||||
"enabled",
|
||||
"description",
|
||||
"deleteAfterRun",
|
||||
"agentId",
|
||||
"sessionKey",
|
||||
"sessionTarget",
|
||||
"wakeMode",
|
||||
"failureAlert",
|
||||
"allowUnsafeExternalContent",
|
||||
]);
|
||||
const synthetic: Record<string, unknown> = {};
|
||||
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<string, unknown>).length === 0
|
||||
) {
|
||||
throw new Error("patch required");
|
||||
}
|
||||
return jsonResult(
|
||||
await callGateway("cron.update", gatewayOpts, {
|
||||
id,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user