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:
Bruno Lorente
2026-04-01 22:41:19 +02:00
committed by GitHub
parent 7027dda8cd
commit ca76e2fedc
10 changed files with 1260 additions and 74 deletions

View 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"]);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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