Files
openclaw/src/agents/tools/cron-tool.test.ts
T
Peter Steinberger efb02a0671 fix(cron): prevent jobs disappearing during paginated refreshes (#121084)
* fix(cron): keep paged consumers on one snapshot

* test(cron): type malformed UI fixture

* test(cron): update canonical list fixtures
2026-08-09 09:05:30 -07:00

3946 lines
127 KiB
TypeScript

// Cron tool tests cover schedule guidance, scoped job operations, delivery
// context inheritance, session routing, and agent id ownership.
import { beforeEach, describe, expect, it, vi } from "vitest";
const { callGatewayMock, extractDeliveryInfoMock } = vi.hoisted(() => ({
callGatewayMock: vi.fn(),
extractDeliveryInfoMock: vi.fn(),
}));
vi.mock("../agent-scope.js", async () => {
const actual = await vi.importActual<typeof import("../agent-scope.js")>("../agent-scope.js");
return {
...actual,
resolveSessionAgentId: actual.resolveSessionAgentId,
};
});
vi.mock("../../config/sessions/delivery-info.js", () => ({
extractDeliveryInfo: extractDeliveryInfoMock,
}));
import { GatewayClientRequestError } from "../../gateway/client.js";
import {
consumeCronCreatorAuthorityGrant,
createCronCreatorAuthorityRunScope,
mintCronCreatorAuthorityGrant,
revokeCronCreatorAuthorityRunScope,
type CronCreatorAuthorityGrant,
} from "../../gateway/cron-creator-authority-grant.js";
import { buildAgentPeerSessionKey } from "../../routing/session-key.js";
import {
bindActiveCronCreatorAuthorityResolver,
runWithCronCreatorAuthority,
runWithCronCreatorAuthorityResolver,
} from "../cron-creator-authority-context.js";
import { createCronTool } from "./cron-tool.js";
import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js";
describe("cron tool", () => {
type SchemaLike = {
anyOf?: Array<SchemaLike>;
description?: string;
properties?: Record<string, SchemaLike>;
type?: string;
};
type TestDelivery = {
mode?: string;
channel?: string;
to?: string;
accountId?: string;
threadId?: string | number;
};
function createTestCronTool(
opts?: Parameters<typeof createCronTool>[0],
): ReturnType<typeof createCronTool> {
return createCronTool(opts, {
callGatewayTool: async (method, gatewayOpts, params) => {
const result = await callGatewayMock({ method, params }, gatewayOpts);
if (
method === "cron.get" &&
result !== null &&
typeof result === "object" &&
!Array.isArray(result) &&
Object.hasOwn(result, "payload") &&
!Object.hasOwn(result, "configRevision")
) {
return { ...result, configRevision: "sha256:test" };
}
return result;
},
});
}
function resolvedCreatorAuthority(
tools: readonly (string | { name: string; pluginId?: string })[],
grant: CronCreatorAuthorityGrant = { runId: "run-test", token: "grant-test" },
) {
return {
tools,
provenance: { version: 1 as const, source: "final-executable-surface" as const },
grant,
};
}
function readGatewayCall(index = 0): { method?: string; params?: Record<string, unknown> } {
return (
(callGatewayMock.mock.calls[index]?.[0] as
| { method?: string; params?: Record<string, unknown> }
| undefined) ?? { method: undefined, params: undefined }
);
}
function readGatewayOpts(index = 0): Record<string, unknown> | undefined {
return callGatewayMock.mock.calls[index]?.[1] as Record<string, unknown> | undefined;
}
function readCronPayloadText(index = 0): string {
const params = readGatewayCall(index).params as { payload?: { text?: string } } | undefined;
return params?.payload?.text ?? "";
}
function expectSingleGatewayCallMethod(method: string) {
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = readGatewayCall(0);
expect(call.method).toBe(method);
return call.params;
}
it("tells models to keep cron expressions in local wall-clock time for tz", () => {
const tool = createTestCronTool();
expect(tool.description).toContain("expr is wall time in tz");
expect(tool.description).toContain("never pre-convert to UTC");
expect(tool.description).toContain("no tz=gateway host local");
expect(tool.description).toContain("no tz=UTC");
expect(tool.description).toContain('expr:"0 18 * * *"');
expect(tool.description).toContain('tz:"Asia/Shanghai"');
});
function buildReminderAgentTurnJob(overrides: Record<string, unknown> = {}): {
name: string;
schedule: { at: string };
payload: { kind: "agentTurn"; message: string };
delivery?: { mode: string; to?: string };
} {
return {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
...overrides,
};
}
async function executeAddAndReadDelivery(params: {
callId: string;
agentSessionKey?: string;
currentDeliveryContext?: NonNullable<
Parameters<typeof createCronTool>[0]
>["currentDeliveryContext"];
delivery?: TestDelivery | null;
}) {
const tool = createTestCronTool({
agentSessionKey: params.agentSessionKey,
currentDeliveryContext: params.currentDeliveryContext,
});
await tool.execute(params.callId, {
action: "add",
job: {
...buildReminderAgentTurnJob(),
...(params.delivery !== undefined ? { delivery: params.delivery } : {}),
},
});
return (readGatewayCall().params as { delivery?: TestDelivery } | undefined)?.delivery;
}
async function executeAddAndReadSessionKey(params: {
callId: string;
agentSessionKey: string;
jobSessionKey?: string;
}): Promise<string | undefined> {
const tool = createTestCronTool({ agentSessionKey: params.agentSessionKey });
await tool.execute(params.callId, {
action: "add",
job: {
name: "wake-up",
schedule: { at: new Date(123).toISOString() },
...(params.jobSessionKey ? { sessionKey: params.jobSessionKey } : {}),
payload: { kind: "systemEvent", text: "hello" },
},
});
const call = readGatewayCall();
const payload = call.params as { sessionKey?: string } | undefined;
return payload?.sessionKey;
}
async function executeAddAndReadAgentId(params: {
callId: string;
agentSessionKey: string;
agentId?: unknown;
includeAgentId?: boolean;
}): Promise<unknown> {
const tool = createTestCronTool({ agentSessionKey: params.agentSessionKey });
await tool.execute(params.callId, {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
...(params.includeAgentId ? { agentId: params.agentId } : {}),
},
});
return readGatewayCall().params?.agentId;
}
async function executeAddWithContextMessages(callId: string, contextMessages: number) {
const tool = createTestCronTool({ agentSessionKey: "main" });
await tool.execute(callId, {
action: "add",
contextMessages,
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
}
beforeEach(() => {
callGatewayMock.mockClear();
callGatewayMock.mockResolvedValue({ ok: true });
extractDeliveryInfoMock.mockReset();
extractDeliveryInfoMock.mockReturnValue({ deliveryContext: undefined, threadId: undefined });
});
it("allows scoped isolated cron runs to remove the current job", async () => {
// Self-removal scope lets a cron-triggered run clean up its own schedule
// without granting broad cron mutation access.
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
await tool.execute("call-self-remove", {
action: "remove",
jobId: "job-current",
});
const params = expectSingleGatewayCallMethod("cron.remove");
expect(params).toEqual({ id: "job-current" });
});
it("denies scoped isolated cron runs from removing another job", async () => {
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
await expect(
tool.execute("call-remove-other", {
action: "remove",
jobId: "job-other",
}),
).rejects.toThrow("Automations tool is restricted to the current automation.");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("allows scoped isolated cron runs to read the current job run history", async () => {
callGatewayMock.mockResolvedValueOnce({
entries: [{ jobId: "job-current", status: "ok" }],
total: 1,
offset: 0,
limit: 50,
hasMore: false,
nextOffset: null,
});
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
const result = await tool.execute("call-self-runs", {
action: "runs",
jobId: "job-current",
});
const params = expectSingleGatewayCallMethod("cron.runs");
expect(params).toEqual({ id: "job-current" });
expect(result.details).toEqual({
entries: [{ jobId: "job-current", status: "ok" }],
total: 1,
offset: 0,
limit: 50,
hasMore: false,
nextOffset: null,
});
});
it.each([
["another job", { action: "runs", jobId: "job-other" }],
["missing job id", { action: "runs" }],
])("denies scoped isolated cron runs from reading %s run history", async (_label, args) => {
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
await expect(tool.execute("call-runs-denied", args)).rejects.toThrow(
"Automations tool is restricted to the current automation.",
);
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("allows scoped isolated cron runs to read cron scheduler status", async () => {
callGatewayMock.mockResolvedValueOnce({
enabled: true,
storePath: "/home/user/.openclaw/cron/jobs.json",
jobs: 37,
nextWakeAtMs: 1_234,
});
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
const result = await tool.execute("call-status", {
action: "status",
timeoutMs: 10_000,
});
const params = expectSingleGatewayCallMethod("cron.status");
expect(params).toStrictEqual({});
expect(result.details).toEqual({ enabled: true });
});
it("passes parsed string timeoutMs values through to gateway calls", async () => {
callGatewayMock.mockResolvedValueOnce({ enabled: true });
const tool = createTestCronTool();
await tool.execute("call-status-timeout", {
action: "status",
timeoutMs: "5000",
});
expectSingleGatewayCallMethod("cron.status");
expect(readGatewayOpts(0)?.timeoutMs).toBe(5000);
});
it("allows scoped isolated cron runs to get the current job", async () => {
callGatewayMock.mockResolvedValueOnce({ id: "job-current", name: "current" });
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
const result = await tool.execute("call-get", {
action: "get",
jobId: "job-current",
});
const params = expectSingleGatewayCallMethod("cron.get");
expect(params).toStrictEqual({ id: "job-current" });
expect(result.details).toEqual({ id: "job-current", name: "current" });
});
it.each([
["another job", { action: "get", jobId: "job-other" }],
["missing job id", { action: "get" }],
])("denies scoped isolated cron runs from getting %s", async (_label, args) => {
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
await expect(tool.execute("call-get-denied", args)).rejects.toThrow(
"Automations tool is restricted to the current automation.",
);
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("allows scoped isolated cron runs to list only the current job", async () => {
callGatewayMock.mockResolvedValueOnce({
jobs: [
{ id: "job-current", name: "current" },
{ id: "job-other", name: "other" },
],
snapshotRevision: "self-list-one-page",
total: 2,
offset: 0,
limit: 2,
hasMore: false,
nextOffset: null,
deliveryPreviews: {
"job-current": { label: "current", detail: "self" },
"job-other": { label: "other", detail: "hidden" },
},
});
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:cron:job-current:run:abc",
selfRemoveOnlyJobId: "job-current",
});
const result = await tool.execute("call-list", {
action: "list",
includeDisabled: true,
});
const params = expectSingleGatewayCallMethod("cron.list");
expect(params).toEqual({
includeDisabled: true,
compact: true,
agentId: "agent-123",
limit: 200,
offset: 0,
});
expect(result.details).toEqual({
jobs: [{ id: "job-current", name: "current" }],
total: 1,
offset: 0,
limit: 1,
hasMore: false,
nextOffset: null,
deliveryPreviews: {
"job-current": { label: "current", detail: "self" },
},
});
});
it("pages scoped isolated cron list until it finds the current job", async () => {
callGatewayMock
.mockResolvedValueOnce({
jobs: Array.from({ length: 200 }, (_, index) => ({
id: `job-old-${index}`,
name: `old ${index}`,
})),
snapshotRevision: "self-list-paged",
total: 201,
offset: 0,
limit: 200,
hasMore: true,
nextOffset: 200,
deliveryPreviews: {},
})
.mockResolvedValueOnce({
jobs: [{ id: "job-current", name: "current" }],
snapshotRevision: "self-list-paged",
total: 201,
offset: 200,
limit: 200,
hasMore: false,
nextOffset: null,
deliveryPreviews: {
"job-current": { label: "current", detail: "self" },
},
});
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:cron:job-current:run:abc",
selfRemoveOnlyJobId: "job-current",
});
const result = await tool.execute("call-list-paged", {
action: "list",
includeDisabled: true,
});
expect(callGatewayMock).toHaveBeenCalledTimes(2);
expect(readGatewayCall(0)).toEqual({
method: "cron.list",
params: {
includeDisabled: true,
compact: true,
agentId: "agent-123",
limit: 200,
offset: 0,
},
});
expect(readGatewayCall(1)).toEqual({
method: "cron.list",
params: {
includeDisabled: true,
compact: true,
agentId: "agent-123",
limit: 200,
offset: 200,
},
});
expect(result.details).toEqual({
jobs: [{ id: "job-current", name: "current" }],
total: 1,
offset: 0,
limit: 1,
hasMore: false,
nextOffset: null,
deliveryPreviews: {
"job-current": { label: "current", detail: "self" },
},
});
});
it("restarts the scoped list when the current job moves behind the page boundary", async () => {
const stableJobs = Array.from({ length: 199 }, (_, index) => ({
id: `stable-${index}`,
name: `stable ${index}`,
}));
callGatewayMock
.mockResolvedValueOnce({
jobs: [{ id: "stale-only", name: "stale" }, ...stableJobs],
snapshotRevision: "revision-a",
total: 201,
offset: 0,
limit: 200,
hasMore: true,
nextOffset: 200,
})
.mockResolvedValueOnce({
jobs: [],
snapshotRevision: "revision-b",
total: 200,
offset: 200,
limit: 200,
hasMore: false,
nextOffset: null,
})
.mockResolvedValueOnce({
jobs: [...stableJobs, { id: "job-current", name: "current" }],
snapshotRevision: "revision-b",
total: 200,
offset: 0,
limit: 200,
hasMore: false,
nextOffset: null,
});
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
const result = await tool.execute("call-list-boundary-churn", { action: "list" });
expect(callGatewayMock.mock.calls.map((call) => call[0].params.offset)).toEqual([0, 200, 0]);
expect(result.details).toEqual({
jobs: [{ id: "job-current", name: "current" }],
total: 1,
offset: 0,
limit: 1,
hasMore: false,
nextOffset: null,
});
});
it("rejects a scoped list after repeated snapshot churn", async () => {
callGatewayMock.mockImplementation(async ({ params }: { params: Record<string, unknown> }) => {
const callNumber = callGatewayMock.mock.calls.length;
const offset = params.offset as number;
if (offset === 0) {
return {
jobs: Array.from({ length: 200 }, (_, index) => ({ id: `job-${callNumber}-${index}` })),
snapshotRevision: `revision-${callNumber}-a`,
total: 201,
offset: 0,
limit: 200,
hasMore: true,
nextOffset: 200,
};
}
return {
jobs: [],
snapshotRevision: `revision-${callNumber}-b`,
total: 200,
offset: 200,
limit: 200,
hasMore: false,
nextOffset: null,
};
});
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
await expect(tool.execute("call-list-churn", { action: "list" })).rejects.toThrow(
"cron.list inventory changed repeatedly while reading current automation",
);
expect(callGatewayMock).toHaveBeenCalledTimes(8);
});
it("does not let requested pagination bypass the scoped current-job scan", async () => {
callGatewayMock.mockResolvedValueOnce({
jobs: [{ id: "job-current", name: "current" }],
snapshotRevision: "self-list-requested-pagination",
total: 1,
offset: 0,
limit: 200,
hasMore: false,
nextOffset: null,
});
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:cron:job-current:run:abc",
selfRemoveOnlyJobId: "job-current",
});
const result = await tool.execute("call-scoped-list-requested-pagination", {
action: "list",
limit: 1,
offset: 200,
});
expectSingleGatewayCallMethod("cron.list");
expect(readGatewayCall().params).toEqual({
includeDisabled: false,
compact: true,
agentId: "agent-123",
limit: 200,
offset: 0,
});
expect(result.details).toMatchObject({
jobs: [{ id: "job-current", name: "current" }],
total: 1,
hasMore: false,
});
});
it.each([
["add", { action: "add", job: buildReminderAgentTurnJob() }],
["update", { action: "update", jobId: "job-current", patch: { enabled: false } }],
["run", { action: "run", jobId: "job-current" }],
["wake", { action: "wake", text: "wake up" }],
])("denies scoped isolated cron runs from using %s", async (_action, args) => {
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
await expect(tool.execute("call-denied", args)).rejects.toThrow(
"Automations tool is restricted to the current automation.",
);
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("filters cron list by the requester agent session", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-list", {
action: "list",
});
const params = expectSingleGatewayCallMethod("cron.list");
expect(params).toEqual({
includeDisabled: false,
compact: true,
agentId: "agent-123",
});
});
it("rejects explicit cron list agent id outside the requester session", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-list-explicit", {
action: "list",
agentId: "ops",
includeDisabled: true,
}),
).rejects.toThrow("cron list agentId must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("preserves explicit agentId for sessionless cron list callers", async () => {
const tool = createTestCronTool();
await tool.execute("call-sessionless-list", {
action: "list",
agentId: "worker",
includeDisabled: true,
});
const params = expectSingleGatewayCallMethod("cron.list");
expect(params).toEqual({
includeDisabled: true,
compact: true,
agentId: "worker",
});
});
it("loads cron jobs beyond the first bounded page", async () => {
const firstPage = {
jobs: Array.from({ length: 200 }, (_, index) => ({
id: `job-${index}`,
name: `job ${index}`,
})),
total: 201,
offset: 0,
limit: 200,
hasMore: true,
nextOffset: 200,
};
const secondPage = {
jobs: [{ id: "job-200", name: "job 200" }],
total: 201,
offset: 200,
limit: 200,
hasMore: false,
nextOffset: null,
};
callGatewayMock.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
const firstResult = await tool.execute("call-list-first-page", {
action: "list",
limit: 200,
offset: 0,
});
const secondResult = await tool.execute("call-list-second-page", {
action: "list",
limit: 200,
offset: firstPage.nextOffset,
});
expect(readGatewayCall(0)).toEqual({
method: "cron.list",
params: {
includeDisabled: false,
compact: true,
agentId: "agent-123",
limit: 200,
offset: 0,
},
});
expect(readGatewayCall(1)).toEqual({
method: "cron.list",
params: {
includeDisabled: false,
compact: true,
agentId: "agent-123",
limit: 200,
offset: 200,
},
});
expect(firstResult.details).toEqual(firstPage);
expect(secondResult.details).toEqual(secondPage);
});
it.each([
["zero limit", { limit: 0 }],
["negative limit", { limit: -1 }],
["fractional limit", { limit: 1.5 }],
["oversized limit", { limit: 201 }],
["unsafe limit", { limit: Number.MAX_SAFE_INTEGER + 1 }],
["malformed limit", { limit: "1x" }],
["negative offset", { offset: -1 }],
["fractional offset", { offset: 1.5 }],
["unsafe offset", { offset: Number.MAX_SAFE_INTEGER + 1 }],
["malformed offset", { offset: "1x" }],
])("rejects a %s before calling the cron gateway", async (_label, pagination) => {
const tool = createTestCronTool();
await expect(
tool.execute("call-invalid-list-pagination", { action: "list", ...pagination }),
).rejects.toThrow(/(?:limit|offset) must be a (?:positive|non-negative) integer/);
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("retries cron.list without compact for older gateways", async () => {
callGatewayMock
.mockRejectedValueOnce(
new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "invalid cron.list params: at root: unexpected property 'compact'",
}),
)
.mockResolvedValueOnce({ jobs: [] });
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-list-older-gateway", { action: "list" });
expect(readGatewayCall(0)).toEqual({
method: "cron.list",
params: {
includeDisabled: false,
compact: true,
agentId: "agent-123",
},
});
expect(readGatewayCall(1)).toEqual({
method: "cron.list",
params: {
includeDisabled: false,
agentId: "agent-123",
},
});
});
describe("wake routing", () => {
// Pin the agentId / sessionKey resolution contract for `action: "wake"`.
// The gateway target resolver treats `agentId` as authoritative, so
// pairing the caller's inferred agentId with a foreign explicit
// sessionKey would canonicalize the wake back to the caller agent's
// main lane.
it("infers sessionKey + agentId from the calling agent's session when neither is supplied", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-wake-default", { action: "wake", text: "ping" });
const params = expectSingleGatewayCallMethod("wake");
expect(params).toEqual({
mode: "next-heartbeat",
text: "ping",
sessionKey: "agent:agent-123:telegram:direct:channing",
agentId: "agent-123",
});
});
it("rejects an explicit cross-agent sessionKey", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-wake-cross-agent", {
action: "wake",
text: "follow up",
sessionKey: "agent:agent-456:discord:thread-xyz",
}),
).rejects.toThrow("automations sessionKey must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects an explicit cross-agent agentId", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-wake-cross-agent-id", {
action: "wake",
text: "follow up",
agentId: "agent-456",
}),
).rejects.toThrow("wake agentId must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects a contradictory explicit agentId + agent-prefixed sessionKey pair", async () => {
// The gateway target resolver treats agentId as authoritative, so a
// contradictory pair would silently canonicalize the wake onto a session
// the caller never named. The tool rejects instead of guessing.
const tool = createTestCronTool();
await expect(
tool.execute("call-wake-explicit-pair", {
action: "wake",
text: "manual",
sessionKey: "agent:agent-456:discord:thread-xyz",
agentId: "ops",
}),
).rejects.toThrow(/contradicts/);
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("accepts a different session owned by the calling agent", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-wake-matching-pair", {
action: "wake",
text: "manual",
sessionKey: "agent:agent-123:discord:thread-xyz",
agentId: "agent-123",
});
const params = expectSingleGatewayCallMethod("wake");
expect(params).toEqual({
mode: "next-heartbeat",
text: "manual",
sessionKey: "agent:agent-123:discord:thread-xyz",
agentId: "agent-123",
});
});
it("binds an unparseable explicit sessionKey to the calling agent", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-wake-unparseable", {
action: "wake",
text: "x",
sessionKey: "subagent:weird:format",
});
const params = expectSingleGatewayCallMethod("wake");
expect(params).toEqual({
mode: "next-heartbeat",
text: "x",
sessionKey: "subagent:weird:format",
agentId: "agent-123",
});
});
it("requires text for action wake", async () => {
// Mutation-test survivor: `required: true` -> false silently sent an
// undefined-text wake. Pin the guard.
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(tool.execute("call-wake-no-text", { action: "wake" })).rejects.toThrow();
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("sends a bare wake when no calling-session context exists", async () => {
// Mutation-test survivor: `opts?.agentSessionKey` -> `opts.agentSessionKey`
// crashed context-less callers. A tool created without session context
// must fall through to default routing, not throw.
const tool = createTestCronTool();
await tool.execute("call-wake-no-context", { action: "wake", text: "ping" });
const params = expectSingleGatewayCallMethod("wake");
expect(params).toEqual({ mode: "next-heartbeat", text: "ping" });
});
it('honours an explicit mode: "next-heartbeat"', async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-wake-nh", { action: "wake", text: "tick", mode: "next-heartbeat" });
const params = expectSingleGatewayCallMethod("wake");
expect(params).toMatchObject({ mode: "next-heartbeat", text: "tick" });
});
it('threads mode: "now" through unchanged', async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-wake-now", { action: "wake", text: "ping", mode: "now" });
const params = expectSingleGatewayCallMethod("wake");
expect(params).toMatchObject({ mode: "now", text: "ping" });
});
});
it("documents deferred follow-up guidance in the tool description", () => {
const tool = createTestCronTool();
expect(tool.description).toContain("reminders, delayed self-wakeups, loops, recurring work");
expect(tool.description).toContain("Never exec sleep/poll as timer.");
expect(tool.description).toContain(
"Inherited configured MCP authority includes only model-callable tools; interactive app-view-only capabilities are excluded from headless jobs.",
);
});
it("documents the event-trigger authoring contract", () => {
const tool = createTestCronTool();
expect(tool.description).toContain(
"needs cron.triggers.enabled — if off, say so; never model-poll instead",
);
expect(tool.description).toContain("Quiet headless check, no model");
expect(tool.description).toContain("trigger.state");
expect(tool.description).toContain("fire:false saves state only");
expect(tool.description).toContain("fire:true runs payload");
expect(tool.description).toContain("Fire on failures/timeouts too");
expect(tool.description).toContain("success-only watchers look healthy when broken");
expect(tool.description).toContain("dedupe via state, never memory");
expect(tool.description).toContain("Script stays read-only; actions belong in payload");
expect(tool.description).toContain("message is that run's entire context — self-contained");
expect(tool.description).toContain('Silent watcher=>mode:"none"');
expect(tool.description).toContain("once:true disables after first fire");
expect(tool.description).toContain('await tools.call("exec"');
});
it("documents due-by-default cron run mode", () => {
const tool = createTestCronTool();
const parameters = tool.parameters as SchemaLike;
const runMode = parameters.properties?.runMode;
expect(tool.description).toContain('run jobId (runMode "force"=now)');
expect(runMode?.description).toContain('omitted defaults to "due"');
expect(runMode?.description).toContain('use "force" to trigger now');
});
it("advertises delivery threadId in the tool schema", () => {
const tool = createTestCronTool();
const parameters = tool.parameters as SchemaLike;
const jobThreadId = parameters.properties?.job?.properties?.delivery?.properties?.threadId;
const patchThreadId = parameters.properties?.patch?.properties?.delivery?.properties?.threadId;
expect(jobThreadId?.description).toContain("Thread/topic id");
expect(jobThreadId?.anyOf?.map((entry) => entry.type)).toEqual(["string", "number"]);
expect(patchThreadId?.description).toContain("Thread/topic id");
expect(patchThreadId?.anyOf?.map((entry) => entry.type)).toEqual(["string", "number", "null"]);
});
it("advertises nullable cron update clears in the tool schema", () => {
const tool = createTestCronTool();
const parameters = tool.parameters as SchemaLike;
const jobDelivery = parameters.properties?.job?.properties?.delivery;
const patch = parameters.properties?.patch;
const payload = patch?.properties?.payload;
const delivery = patch?.properties?.delivery;
const jobPacing = parameters.properties?.job?.properties?.pacing;
const patchPacing = patch?.properties?.pacing?.anyOf?.find((entry) => entry.type === "object");
expect(jobDelivery?.properties?.channel?.anyOf).toBeUndefined();
expect(jobDelivery?.properties?.channel?.type).toBe("string");
expect(jobDelivery?.properties?.failureDestination?.anyOf).toBeUndefined();
expect(jobDelivery?.properties?.failureDestination?.type).toBe("object");
expect(patch?.properties?.agentId?.anyOf?.map((entry) => entry.type)).toEqual([
"string",
"null",
]);
expect(patch?.properties?.agentId?.type).toBeUndefined();
expect(patch?.properties?.agentId?.description).toContain("null to clear");
expect(patch?.properties?.sessionKey?.anyOf?.map((entry) => entry.type)).toEqual([
"string",
"null",
]);
expect(patch?.properties?.sessionKey?.type).toBeUndefined();
expect(patch?.properties?.sessionKey?.description).toContain("null to clear");
expect(payload?.properties?.toolsAllow?.anyOf?.map((entry) => entry.type)).toEqual([
"array",
"null",
]);
expect(payload?.properties?.toolsAllow?.type).toBeUndefined();
expect(payload?.properties?.toolsAllow?.description).toContain("null to clear");
expect(delivery?.properties?.channel?.anyOf?.map((entry) => entry.type)).toEqual([
"string",
"null",
]);
expect(delivery?.properties?.channel?.type).toBeUndefined();
expect(delivery?.properties?.channel?.description).toContain("null to clear");
expect(delivery?.properties?.failureDestination?.anyOf?.map((entry) => entry.type)).toEqual([
"object",
"null",
]);
expect(jobPacing?.description).toContain("at least one of min or max is required");
expect(patchPacing?.description).toContain("at least one of min or max is required");
});
it.each([
[
"update",
{ action: "update", jobId: "job-1", patch: { foo: "bar" } },
{ id: "job-1", patch: { foo: "bar" } },
],
[
"update",
{ action: "update", id: "job-2", patch: { foo: "bar" } },
{ id: "job-2", patch: { foo: "bar" } },
],
["remove", { action: "remove", jobId: "job-1" }, { id: "job-1" }],
["remove", { action: "remove", id: "job-2" }, { id: "job-2" }],
["run", { action: "run", jobId: "job-1" }, { id: "job-1", mode: "due" }],
["run", { action: "run", id: "job-2" }, { id: "job-2", mode: "due" }],
["get", { action: "get", jobId: "job-1" }, { id: "job-1" }],
["get", { action: "get", id: "job-2" }, { id: "job-2" }],
["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }],
["runs", { action: "runs", id: "job-2" }, { id: "job-2" }],
])("%s sends id to gateway", async (action, args, expectedParams) => {
const tool = createTestCronTool();
await tool.execute("call1", args);
const params = expectSingleGatewayCallMethod(`cron.${action}`);
expect(params).toEqual(expectedParams);
});
it("prefers jobId over id when both are provided", async () => {
const tool = createTestCronTool();
await tool.execute("call1", {
action: "run",
jobId: "job-primary",
id: "job-legacy",
});
expect(readGatewayCall().params).toEqual({
id: "job-primary",
mode: "due",
});
});
it("supports due-only run mode", async () => {
const tool = createTestCronTool();
await tool.execute("call-due", {
action: "run",
jobId: "job-due",
runMode: "due",
});
expect(readGatewayCall().params).toEqual({
id: "job-due",
mode: "due",
});
});
it("supports force run mode", async () => {
const tool = createTestCronTool();
await tool.execute("call-force", {
action: "run",
jobId: "job-force",
runMode: "force",
});
expect(readGatewayCall().params).toEqual({
id: "job-force",
mode: "force",
});
});
it("normalizes cron.add job payloads", async () => {
const tool = createTestCronTool();
await tool.execute("call2", {
action: "add",
job: {
data: {
name: "wake-up",
schedule: { atMs: 123 },
payload: { kind: "systemEvent", text: "hello" },
},
},
});
const params = expectSingleGatewayCallMethod("cron.add");
expect(params).toEqual({
name: "wake-up",
enabled: true,
deleteAfterRun: true,
schedule: { kind: "at", at: new Date(123).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "hello" },
});
});
it("preserves omitted declaration enablement and forwards explicit enablement", async () => {
const tool = createTestCronTool();
const baseJob = {
name: "wake-up",
declarationKey: "daily-wake",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent" as const, text: "hello" },
};
await tool.execute("call-declaration-default", { action: "add", job: baseJob });
expect(readGatewayCall(0).params).not.toHaveProperty("enabled");
await tool.execute("call-declaration-disabled", {
action: "add",
job: { ...baseJob, enabled: false },
});
expect(readGatewayCall(1).params).toMatchObject({ enabled: false });
});
it("rejects blank declaration keys before create normalization", async () => {
const tool = createTestCronTool();
await expect(
tool.execute("call-blank-declaration", {
action: "add",
job: {
name: "wake-up",
declarationKey: " ",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "hello" },
},
}),
).rejects.toThrow("declarationKey must be a non-empty string");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects blank display names before create and patch normalization", async () => {
const tool = createTestCronTool();
await expect(
tool.execute("call-blank-display-add", {
action: "add",
job: {
name: "wake-up",
declarationKey: "daily",
displayName: " ",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "hello" },
},
}),
).rejects.toThrow("displayName must be a non-empty string");
await expect(
tool.execute("call-blank-display-update", {
action: "update",
jobId: "daily",
patch: { displayName: " " },
}),
).rejects.toThrow("displayName must be a non-empty string or null");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it.each([
[
"add",
{
action: "add",
job: { ...buildReminderAgentTurnJob(), pacing: {} },
},
],
[
"update",
{
action: "update",
jobId: "paced-job",
patch: { pacing: {} },
},
],
])("rejects empty pacing on cron.%s before calling the gateway", async (_action, args) => {
const tool = createTestCronTool();
await expect(tool.execute("call-empty-pacing", args)).rejects.toThrow(
"cron pacing requires at least one of min or max",
);
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects null agentId on add from the scoped agent cron tool", async () => {
const tool = createTestCronTool({ agentSessionKey: "main" });
await expect(
tool.execute("call-null", {
action: "add",
job: {
name: "wake-up",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "hello" },
agentId: null,
},
}),
).rejects.toThrow("automation agentId must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("preserves explicit agentId for sessionless cron add callers", async () => {
const tool = createTestCronTool();
await tool.execute("call-sessionless-add", {
action: "add",
job: {
name: "worker job",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
agentId: "worker",
},
});
const params = expectSingleGatewayCallMethod("cron.add");
expect(params).toMatchObject({
name: "worker job",
agentId: "worker",
payload: { kind: "agentTurn", message: "hello" },
});
expect(params).not.toHaveProperty("callerScope");
});
it("infers session agentId when job.agentId is omitted", async () => {
await expect(
executeAddAndReadAgentId({
callId: "call-omitted-agent-id",
agentSessionKey: "agent:agent-123:telegram:direct:channing",
}),
).resolves.toBe("agent-123");
});
it("infers session agentId when job.agentId is undefined", async () => {
await expect(
executeAddAndReadAgentId({
callId: "call-undefined-agent-id",
agentSessionKey: "agent:agent-123:telegram:direct:channing",
includeAgentId: true,
agentId: undefined,
}),
).resolves.toBe("agent-123");
});
it("accepts matching explicit agentId on add", async () => {
await expect(
executeAddAndReadAgentId({
callId: "call-matching-agent-id",
agentSessionKey: "agent:agent-123:telegram:direct:channing",
includeAgentId: true,
agentId: "agent-123",
}),
).resolves.toBe("agent-123");
});
it("rejects foreign explicit agentId on add", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-foreign-agent-id", {
action: "add",
job: {
name: "foreign",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
agentId: "worker",
},
}),
).rejects.toThrow("automation agentId must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects foreign agent-prefixed session refs on add", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-foreign-session-ref", {
action: "add",
job: {
name: "foreign session",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
sessionTarget: "session:agent:worker:telegram:direct:alice",
},
}),
).rejects.toThrow("automations sessionTarget must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("does not forward model-supplied callerScope", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-spoofed-caller-scope", {
action: "remove",
jobId: "job-1",
callerScope: { kind: "agentTool", agentId: "worker" },
});
expect(readGatewayCall().params).toEqual({
id: "job-1",
});
});
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.each([
["canonical", "command"],
["mixed-case", "Command"],
])("rejects %s command payloads from the agent cron tool on add", async (_case, kind) => {
const tool = createTestCronTool();
await expect(
tool.execute("call-command-add", {
action: "add",
job: {
name: "command",
schedule: { at: new Date(123).toISOString() },
sessionTarget: "isolated",
payload: { kind, argv: ["sh", "-lc", "echo ok"] },
},
}),
).rejects.toThrow("automation command payloads cannot be created or edited");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("allows script payloads without treating them as shell commands", async () => {
const tool = createTestCronTool();
await tool.execute("call-script-add", {
action: "add",
job: {
name: "script",
schedule: { at: new Date(123).toISOString() },
sessionTarget: "isolated",
payload: {
kind: "script",
script: "return { notify: 'done' }",
timeoutSeconds: 300,
toolBudget: 50,
},
},
});
expect(expectSingleGatewayCallMethod("cron.add")).toMatchObject({
payload: {
kind: "script",
script: "return { notify: 'done' }",
timeoutSeconds: 300,
toolBudget: 50,
},
});
});
it("rejects on-exit schedules from the agent cron tool on add", async () => {
const tool = createTestCronTool();
await expect(
tool.execute("call-on-exit-add", {
action: "add",
job: {
name: "watch command",
schedule: { kind: "on-exit", command: "make" },
payload: { kind: "agentTurn", message: "done" },
},
}),
).rejects.toThrow("automation on-exit schedules cannot be created or edited");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("forwards stream schedules to the Gateway trigger-trust gate", async () => {
const tool = createTestCronTool();
await tool.execute("call-stream-add", {
action: "add",
job: {
name: "watch events",
schedule: {
kind: "stream",
command: ["node", "events.mjs"],
mode: "match",
match: "^ready:",
},
payload: { kind: "agentTurn", message: "handle events" },
},
});
expect(expectSingleGatewayCallMethod("cron.add")?.schedule).toEqual({
kind: "stream",
command: ["node", "events.mjs"],
mode: "match",
match: "^ready:",
});
});
it.each([
["delivery.channel", { channel: " ", to: "chat-1" }],
["delivery.channel", { channel: 123, to: "chat-1" }],
["delivery.to", { mode: "announce", channel: "telegram", to: " \t" }],
["delivery.to", { mode: "announce", channel: "telegram", to: {} }],
[
"delivery.failureDestination.to",
{ mode: "announce", failureDestination: { mode: "announce", to: " " } },
],
[
"delivery.failureDestination.to",
{ mode: "announce", failureDestination: { mode: "announce", to: false } },
],
[
"delivery.completionDestination.to",
{ mode: "announce", completionDestination: { mode: "webhook", to: "\n" } },
],
])("rejects invalid cron.add %s before gateway normalization", async (field, delivery) => {
const tool = createTestCronTool();
await expect(
tool.execute("call-blank-delivery-add", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
delivery,
},
}),
).rejects.toThrow(`${field} must be a non-empty string`);
expect(callGatewayMock).not.toHaveBeenCalled();
});
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("caps agentTurn add toolsAllow to the creator tool surface", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
});
await tool.execute("call-capped-add-tools", {
action: "add",
job: {
...buildReminderAgentTurnJob(),
payload: {
kind: "agentTurn",
message: "hello",
toolsAllow: ["exec", "read"],
},
},
});
const params = expectSingleGatewayCallMethod("cron.add") as
| { payload?: { toolsAllow?: string[] } }
| undefined;
expect(params?.payload?.toolsAllow).toEqual(["read"]);
});
it("stores the creator tool surface on agentTurn adds without explicit toolsAllow", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
});
await tool.execute("call-default-capped-add-tools", {
action: "add",
job: buildReminderAgentTurnJob(),
});
const params = expectSingleGatewayCallMethod("cron.add") as
| { payload?: { toolsAllow?: string[] } }
| undefined;
expect(params?.payload?.toolsAllow).toEqual(["read", "automations"]);
});
it("lazily snapshots configured MCP authority for a default agentTurn add", async () => {
const identities: unknown[] = [];
callGatewayMock.mockImplementation(async () => {
identities.push(getGatewayToolCallerIdentity());
return { ok: true };
});
const resolveCreatorToolAuthority = vi.fn(async () =>
resolvedCreatorAuthority(["read", "cron", "configured__lookup"]),
);
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
creatorToolAllowlist: ["read", "cron"],
resolveCreatorToolAuthority,
});
await tool.execute("call-default-configured-mcp", {
action: "add",
job: buildReminderAgentTurnJob(),
});
expect(resolveCreatorToolAuthority).toHaveBeenCalledOnce();
expect(readGatewayCall().params).toMatchObject({
payload: {
toolsAllow: ["read", "automations", "configured__lookup"],
toolsAllowIsDefault: true,
},
});
expect(identities).toEqual([
expect.objectContaining({ cronToolsAllowCapture: "final-executable-surface" }),
]);
});
it("does not write when the admitted run aborts while lazy authority resolves", async () => {
let finishResolution!: () => void;
const resolution = new Promise<void>((resolve) => {
finishResolution = resolve;
});
const abortController = new AbortController();
const run = runWithCronCreatorAuthority(
"run-timeout",
() => {
const resolveCreatorToolAuthority = runWithCronCreatorAuthorityResolver({
runId: "run-timeout",
resolve: async () => {
await resolution;
return {
tools: ["read", "configured__lookup"],
provenance: { version: 1, source: "final-executable-surface" },
};
},
run: () => bindActiveCronCreatorAuthorityResolver("run-timeout"),
});
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
resolveCreatorToolAuthority,
});
return tool.execute("call-late-authority-timeout", {
action: "add",
job: buildReminderAgentTurnJob(),
});
},
abortController.signal,
);
abortController.abort(new Error("run timed out"));
finishResolution();
await expect(run).rejects.toThrow();
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("does not mint or write when the exact cron tool call aborts during discovery", async () => {
let finishResolution!: () => void;
let discoverySignal: AbortSignal | undefined;
const resolution = new Promise<void>((resolve) => {
finishResolution = resolve;
});
const operation = new AbortController();
const run = runWithCronCreatorAuthority("run-operation-timeout", () => {
const resolveCreatorToolAuthority = runWithCronCreatorAuthorityResolver({
runId: "run-operation-timeout",
resolve: async (options) => {
discoverySignal = options?.signal;
await resolution;
return {
tools: ["read", "configured__lookup"],
provenance: { version: 1, source: "final-executable-surface" },
};
},
run: () => bindActiveCronCreatorAuthorityResolver("run-operation-timeout"),
});
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
resolveCreatorToolAuthority,
});
return tool.execute(
"call-operation-authority-timeout",
{ action: "add", job: buildReminderAgentTurnJob() },
operation.signal,
);
});
operation.abort(new Error("cron tool call timed out"));
finishResolution();
await expect(run).rejects.toThrow("cron tool call timed out");
expect(discoverySignal?.aborted).toBe(true);
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("lets a later cron operation rematerialize after an earlier operation abort", async () => {
let finishFirstResolution!: () => void;
const firstResolution = new Promise<void>((resolve) => {
finishFirstResolution = resolve;
});
let materializations = 0;
callGatewayMock.mockImplementation(async () => {
const grant = getGatewayToolCallerIdentity()?.cronCreatorAuthorityGrant;
expect(grant).toBeDefined();
consumeCronCreatorAuthorityGrant(grant!);
return { ok: true };
});
await runWithCronCreatorAuthority("run-operation-retry", async () => {
const resolveCreatorToolAuthority = runWithCronCreatorAuthorityResolver({
runId: "run-operation-retry",
resolve: async () => {
materializations += 1;
if (materializations === 1) {
await firstResolution;
}
return {
tools: ["read", "configured__lookup"],
provenance: { version: 1, source: "final-executable-surface" },
};
},
run: () => bindActiveCronCreatorAuthorityResolver("run-operation-retry"),
});
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
resolveCreatorToolAuthority,
});
const firstOperation = new AbortController();
const firstWrite = tool.execute(
"call-operation-retry-first",
{ action: "add", job: buildReminderAgentTurnJob() },
firstOperation.signal,
);
firstOperation.abort(new Error("first cron call timed out"));
finishFirstResolution();
await expect(firstWrite).rejects.toThrow("first cron call timed out");
expect(callGatewayMock).not.toHaveBeenCalled();
await tool.execute(
"call-operation-retry-second",
{ action: "add", job: buildReminderAgentTurnJob() },
new AbortController().signal,
);
});
expect(materializations).toBe(2);
expect(callGatewayMock).toHaveBeenCalledOnce();
});
it("does not commit when the exact cron tool call aborts after grant mint", async () => {
const operation = new AbortController();
let committedWrites = 0;
callGatewayMock.mockImplementation(async () => {
const grant = getGatewayToolCallerIdentity()?.cronCreatorAuthorityGrant;
expect(grant).toBeDefined();
operation.abort(new Error("cron tool call timed out before commit"));
consumeCronCreatorAuthorityGrant(grant!);
committedWrites += 1;
return { ok: true };
});
const run = runWithCronCreatorAuthority("run-abort-before-commit", () => {
const resolveCreatorToolAuthority = runWithCronCreatorAuthorityResolver({
runId: "run-abort-before-commit",
resolve: async () => ({
tools: ["read", "configured__lookup"],
provenance: { version: 1, source: "final-executable-surface" },
}),
run: () => bindActiveCronCreatorAuthorityResolver("run-abort-before-commit"),
});
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
resolveCreatorToolAuthority,
});
return tool.execute(
"call-abort-before-commit",
{ action: "add", job: buildReminderAgentTurnJob() },
operation.signal,
);
});
await expect(run).rejects.toThrow("Configured MCP cron authority is no longer active");
expect(committedWrites).toBe(0);
});
it("fails a queued configured-MCP default add visibly without writing", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
creatorToolAllowlist: ["read", "cron"],
creatorAuthorityUnavailableReason: "queued-local-operator-configured-mcp",
});
await expect(
tool.execute("call-queued-configured-mcp-add", {
action: "add",
job: buildReminderAgentTurnJob(),
}),
).rejects.toThrow("fresh authenticated direct-local operator turn");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it.each([
["finite", ["read"]],
["empty", []],
])("keeps an explicit %s add offline and exact", async (_label, toolsAllow) => {
const resolveCreatorToolAuthority = vi.fn(async () => {
throw new Error("must stay offline");
});
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
creatorToolAllowlist: ["read", "cron"],
resolveCreatorToolAuthority,
});
await tool.execute("call-explicit-configured-mcp", {
action: "add",
job: {
...buildReminderAgentTurnJob(),
payload: { kind: "agentTurn", message: "hello", toolsAllow },
},
});
expect(resolveCreatorToolAuthority).not.toHaveBeenCalled();
expect(readGatewayCall().params).toMatchObject({ payload: { toolsAllow } });
});
it("resolves an unknown finite add name and cannot pre-authorize a future tool", async () => {
const resolveCreatorToolAuthority = vi.fn(async () => resolvedCreatorAuthority(["read"]));
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
creatorToolAllowlist: ["read"],
resolveCreatorToolAuthority,
});
await tool.execute("call-future-configured-mcp", {
action: "add",
job: {
...buildReminderAgentTurnJob(),
payload: { kind: "agentTurn", message: "hello", toolsAllow: ["future__tool"] },
},
});
expect(resolveCreatorToolAuthority).toHaveBeenCalledOnce();
expect(readGatewayCall().params).toMatchObject({ payload: { toolsAllow: [] } });
});
it("keeps future-tool prevention for complete runtimes without a capture marker", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
creatorToolAllowlist: ["read"],
});
await tool.execute("call-future-no-capture-marker", {
action: "add",
job: {
...buildReminderAgentTurnJob(),
payload: { kind: "agentTurn", message: "hello", toolsAllow: ["future__tool"] },
},
});
expect(readGatewayCall().params).toMatchObject({ payload: { toolsAllow: [] } });
});
it("resolves symbolic groups before persisting an add cap", async () => {
const resolveCreatorToolAuthority = vi.fn(async () =>
resolvedCreatorAuthority(["read", { name: "configured__lookup", pluginId: "bundle-mcp" }]),
);
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
resolveCreatorToolAuthority,
});
await tool.execute("call-symbolic-configured-mcp", {
action: "add",
job: {
...buildReminderAgentTurnJob(),
payload: { kind: "agentTurn", message: "hello", toolsAllow: ["group:plugins"] },
},
});
expect(resolveCreatorToolAuthority).toHaveBeenCalledOnce();
expect(readGatewayCall().params).toMatchObject({
payload: { toolsAllow: ["configured__lookup"] },
});
});
it("does not write a default add when configured MCP authentication fails", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
resolveCreatorToolAuthority: async () => {
throw new Error("Sign in to configured MCP, then retry; no automation changes were saved.");
},
});
await expect(
tool.execute("call-default-auth-failure", {
action: "add",
job: buildReminderAgentTurnJob(),
}),
).rejects.toThrow("no automation changes were saved");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("fails incomplete inherited and unknown finite adds while preserving known finite tools", async () => {
const captureRef = {};
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
creatorToolAllowlistCaptureRef: captureRef,
});
await expect(
tool.execute("call-default-capture-unavailable", {
action: "add",
job: buildReminderAgentTurnJob(),
}),
).rejects.toThrow("fresh authenticated direct-local operator turn");
expect(callGatewayMock).not.toHaveBeenCalled();
await expect(
tool.execute("call-unknown-finite-capture-unavailable", {
action: "add",
job: {
...buildReminderAgentTurnJob(),
payload: {
kind: "agentTurn",
message: "hello",
toolsAllow: ["future__tool"],
},
},
}),
).rejects.toThrow("CLI or Gateway with an explicit finite toolsAllow list");
expect(callGatewayMock).not.toHaveBeenCalled();
await tool.execute("call-explicit-capture-unavailable", {
action: "add",
job: {
...buildReminderAgentTurnJob(),
payload: { kind: "agentTurn", message: "hello", toolsAllow: ["read"] },
},
});
expect(expectSingleGatewayCallMethod("cron.add")).toMatchObject({
payload: { toolsAllow: ["read"] },
});
});
it("caps trigger-script systemEvent adds to the creator tool surface", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
});
await tool.execute("call-capped-trigger-system-event", {
action: "add",
job: {
name: "watcher",
schedule: { kind: "every", everyMs: 60_000 },
trigger: { script: "return { fire: false }" },
sessionTarget: "main",
payload: { kind: "systemEvent", text: "changed" },
},
});
const params = expectSingleGatewayCallMethod("cron.add") as
| { payload?: { toolsAllow?: string[] } }
| undefined;
expect(params?.payload?.toolsAllow).toEqual(["read", "automations"]);
});
it("infers systemEvent for implicit text payloads with toolsAllow", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-implicit-system-event-tools", {
action: "add",
job: {
name: "implicit system event",
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
payload: { text: "tick", toolsAllow: ["read"] },
},
});
const params = expectSingleGatewayCallMethod("cron.add") as
| { payload?: { kind?: string; text?: string; toolsAllow?: string[] } }
| undefined;
expect(params?.payload).toEqual({
kind: "systemEvent",
text: "tick",
toolsAllow: ["read"],
});
});
it("caps trigger-script systemEvent updates to the creator tool surface", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-trigger",
payload: { kind: "systemEvent", text: "changed" },
})
.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
});
await tool.execute("call-capped-trigger-system-event-update", {
action: "update",
id: "job-trigger",
patch: { trigger: { script: "return { fire: false }" } },
});
expect(readGatewayCall(1)).toEqual({
method: "cron.update",
params: {
id: "job-trigger",
expectedConfigRevision: "sha256:test",
patch: {
trigger: { script: "return { fire: false }" },
payload: {
kind: "systemEvent",
toolsAllow: ["read", "automations"],
toolsAllowIsDefault: true,
},
},
},
});
});
it("caps dormant systemEvent toolsAllow updates without relying on trigger state", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
});
await tool.execute("call-capped-dormant-system-event-update", {
action: "update",
id: "job-dormant",
patch: {
payload: { kind: "systemEvent", toolsAllow: ["read", "exec"] },
},
});
expect(readGatewayCall()).toEqual({
method: "cron.update",
params: {
id: "job-dormant",
patch: { payload: { kind: "systemEvent", toolsAllow: ["read"] } },
},
});
});
it("preserves explicit empty agentTurn add toolsAllow under a creator tool surface", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
});
await tool.execute("call-empty-capped-add-tools", {
action: "add",
job: {
...buildReminderAgentTurnJob(),
payload: {
kind: "agentTurn",
message: "hello",
toolsAllow: [],
},
},
});
const params = expectSingleGatewayCallMethod("cron.add") as
| { payload?: { toolsAllow?: string[] } }
| undefined;
expect(params?.payload?.toolsAllow).toEqual([]);
});
it("expands plugin selectors against the creator tool surface on agentTurn adds", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: [
{ name: "active_memory_search", pluginId: "active-memory" },
{ name: "active_memory_store", pluginId: "active-memory" },
{ name: "cron" },
],
});
await tool.execute("call-capped-add-plugin-tools", {
action: "add",
job: {
...buildReminderAgentTurnJob(),
payload: {
kind: "agentTurn",
message: "hello",
toolsAllow: ["active-memory", "cron", "exec"],
},
},
});
const params = expectSingleGatewayCallMethod("cron.add") as
| { payload?: { toolsAllow?: string[] } }
| undefined;
expect(params?.payload?.toolsAllow).toEqual([
"active_memory_search",
"active_memory_store",
"automations",
]);
});
it("expands group:plugins against the creator tool surface on agentTurn adds", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: [
{ name: "active_memory_search", pluginId: "active-memory" },
{ name: "cron" },
],
});
await tool.execute("call-capped-add-plugin-group", {
action: "add",
job: {
...buildReminderAgentTurnJob(),
payload: {
kind: "agentTurn",
message: "hello",
toolsAllow: ["group:plugins"],
},
},
});
const params = expectSingleGatewayCallMethod("cron.add") as
| { payload?: { toolsAllow?: string[] } }
| undefined;
expect(params?.payload?.toolsAllow).toEqual(["active_memory_search"]);
});
it("recovers concatenated cron add keys from local tool-call parsers", async () => {
const tool = createTestCronTool();
await tool.execute("call-concatenated-add", {
action: "add",
job: {
delivery: { mode: "none" },
enabled: true,
namePayload: { kind: "agentTurn", message: "Evidence test.", timeoutSeconds: 10 },
scheduleKind: { everyMs: 999_999, kind: "every" },
sessionTargetName: "evidence-test",
},
});
const params = expectSingleGatewayCallMethod("cron.add");
expect(params).toEqual({
delivery: { mode: "none" },
enabled: true,
name: "evidence-test",
payload: { kind: "agentTurn", message: "Evidence test.", timeoutSeconds: 10 },
schedule: { everyMs: 999_999, kind: "every" },
sessionTarget: "isolated",
wakeMode: "now",
});
});
it("recovers flat concatenated cron add keys from local tool-call parsers", async () => {
const tool = createTestCronTool();
await tool.execute("call-flat-concatenated-add", {
action: "add",
delivery: { mode: "none" },
enabled: true,
namePayload: { kind: "agentTurn", message: "Evidence test.", timeoutSeconds: 10 },
scheduleKind: { everyMs: 999_999, kind: "every" },
sessionTargetName: "evidence-test",
});
const params = expectSingleGatewayCallMethod("cron.add");
expect(params).toEqual({
delivery: { mode: "none" },
enabled: true,
name: "evidence-test",
payload: { kind: "agentTurn", message: "Evidence test.", timeoutSeconds: 10 },
schedule: { everyMs: 999_999, kind: "every" },
sessionTarget: "isolated",
wakeMode: "now",
});
});
it("stamps cron.add with caller sessionKey when missing", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const callerSessionKey = "agent:main:discord:channel:ops";
const sessionKey = await executeAddAndReadSessionKey({
callId: "call-session-key",
agentSessionKey: callerSessionKey,
});
expect(sessionKey).toBe(callerSessionKey);
});
it("defaults scoped agentTurn adds to the creating conversation", async () => {
const callerSessionKey = "agent:main:discord:channel:ops";
const tool = createTestCronTool({ agentSessionKey: callerSessionKey });
await tool.execute("call-current-default", {
action: "add",
job: buildReminderAgentTurnJob(),
});
expect(expectSingleGatewayCallMethod("cron.add")).toMatchObject({
sessionTarget: "current",
sessionKey: callerSessionKey,
delivery: { mode: "announce" },
});
});
it("forwards authenticated source account separately from delivery account", async () => {
let identity: ReturnType<typeof getGatewayToolCallerIdentity> = undefined;
const tool = createCronTool(
{
agentSessionKey: "agent:main:discord:channel:ops",
agentAccountId: "source-account",
selfRemoveOnlyJobId: "job-current",
currentDeliveryContext: { accountId: "delivery-account" },
},
{
callGatewayTool: async <T>() => {
identity = getGatewayToolCallerIdentity();
return { enabled: true, jobs: 0 } as T;
},
},
);
await tool.execute("call-source-account", { action: "status" });
expect(identity).toMatchObject({
agentId: "main",
sessionKey: "agent:main:discord:channel:ops",
turnSourceAccountId: "source-account",
cronSelfManagementJobId: "job-current",
});
});
it("preserves explicit job.sessionKey on add", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const sessionKey = await executeAddAndReadSessionKey({
callId: "call-explicit-session-key",
agentSessionKey: "agent:main:discord:channel:ops",
jobSessionKey: "agent:main:telegram:group:-100123:topic:99",
});
expect(sessionKey).toBe("agent:main:telegram:group:-100123:topic:99");
});
it("does not stamp caller sessionKey when add targets isolated session", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({ agentSessionKey: "agent:main:webchat:dm:dashboard" });
await tool.execute("call-isolated-no-stamp", {
action: "add",
job: {
name: "isolated run",
schedule: { at: new Date(123).toISOString() },
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "hello" },
},
});
const call = readGatewayCall();
const payload = call.params as { sessionKey?: string; sessionTarget?: string } | undefined;
expect(payload?.sessionTarget).toBe("isolated");
expect(payload).not.toHaveProperty("sessionKey");
});
it("adds recent context for systemEvent reminders when contextMessages > 0", async () => {
callGatewayMock
.mockResolvedValueOnce({
messages: [
{ role: "user", content: [{ type: "text", text: "Discussed Q2 budget" }] },
{
role: "assistant",
content: [{ type: "text", text: "We agreed to review on Tuesday." }],
},
{ role: "user", content: [{ type: "text", text: "Remind me about the thing at 2pm" }] },
],
})
.mockResolvedValueOnce({ ok: true });
await executeAddWithContextMessages("call3", 3);
expect(callGatewayMock).toHaveBeenCalledTimes(2);
const historyCall = readGatewayCall(0);
expect(historyCall.method).toBe("chat.history");
const cronCall = readGatewayCall(1);
expect(cronCall.method).toBe("cron.add");
const text = readCronPayloadText(1);
expect(text).toContain("Recent context:");
expect(text).toContain("User: Discussed Q2 budget");
expect(text).toContain("Assistant: We agreed to review on Tuesday.");
expect(text).toContain("User: Remind me about the thing at 2pm");
});
it("caps contextMessages at 10", async () => {
const messages = Array.from({ length: 12 }, (_, idx) => ({
role: "user",
content: [{ type: "text", text: `Message ${idx + 1}` }],
}));
callGatewayMock.mockResolvedValueOnce({ messages }).mockResolvedValueOnce({ ok: true });
await executeAddWithContextMessages("call5", 20);
expect(callGatewayMock).toHaveBeenCalledTimes(2);
const historyCall = readGatewayCall(0);
expect(historyCall.method).toBe("chat.history");
const historyParams = historyCall.params as { limit?: number } | undefined;
expect(historyParams?.limit).toBe(10);
const text = readCronPayloadText(1);
expect(text).not.toMatch(/Message 1\\b/);
expect(text).not.toMatch(/Message 2\\b/);
expect(text).toContain("Message 3");
expect(text).toContain("Message 12");
});
it.each([1.5, -1, "2messages"])(
"rejects invalid contextMessages value %s",
async (contextMessages) => {
const tool = createTestCronTool({ agentSessionKey: "main" });
await expect(
tool.execute("call-invalid-context", {
action: "add",
contextMessages,
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
}),
).rejects.toThrow("contextMessages must be a non-negative integer");
expect(callGatewayMock).not.toHaveBeenCalled();
},
);
it("does not add context when contextMessages is 0 (default)", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({ agentSessionKey: "main" });
await tool.execute("call4", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { text: "Reminder: the thing." },
},
});
// Should only call cron.add, not chat.history
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const cronCall = readGatewayCall(0);
expect(cronCall.method).toBe("cron.add");
const text = readCronPayloadText(0);
expect(text).not.toContain("Recent context:");
});
it("rejects explicit agentId null on add", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({ agentSessionKey: "main" });
await expect(
tool.execute("call6", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
agentId: null,
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
}),
).rejects.toThrow("automation agentId must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("does not infer delivery from raw session-key fragments without delivery context", async () => {
const slackDelivery = await executeAddAndReadDelivery({
callId: "call-thread",
agentSessionKey: "agent:main:slack:channel:general:thread:1699999999.0001",
});
const telegramDelivery = await executeAddAndReadDelivery({
callId: "call-telegram-topic",
agentSessionKey: "agent:main:telegram:group:-1001234567890:topic:99",
});
expect(slackDelivery?.channel).toBeUndefined();
expect(slackDelivery?.to).toBeUndefined();
expect(telegramDelivery?.channel).toBeUndefined();
expect(telegramDelivery?.to).toBeUndefined();
});
it("uses stored delivery context when current context is unavailable", async () => {
extractDeliveryInfoMock.mockReturnValueOnce({
deliveryContext: {
channel: "matrix",
to: "room:!AbCdEf1234567890:example.org",
accountId: "bot-a",
threadId: "$RootEvent:Example.Org",
},
threadId: undefined,
});
expect(
await executeAddAndReadDelivery({
callId: "call-stored-context",
agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org",
}),
).toEqual({
mode: "announce",
channel: "matrix",
to: "room:!AbCdEf1234567890:example.org",
accountId: "bot-a",
threadId: "$RootEvent:Example.Org",
});
});
it("prefers current delivery context over stored session context", async () => {
extractDeliveryInfoMock.mockReturnValueOnce({
deliveryContext: {
channel: "matrix",
to: "!stored:example.org",
},
threadId: undefined,
});
expect(
await executeAddAndReadDelivery({
callId: "call-current-context",
agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org",
currentDeliveryContext: {
channel: "matrix",
to: "room:!AbCdEf1234567890:example.org",
accountId: "bot-a",
threadId: "$RootEvent:Example.Org",
},
}),
).toEqual({
mode: "announce",
channel: "matrix",
to: "room:!AbCdEf1234567890:example.org",
accountId: "bot-a",
threadId: "$RootEvent:Example.Org",
});
});
it("does not surface lowercased LINE recipients when current delivery context is unavailable (#81628)", async () => {
// LINE chat IDs are case-sensitive; without current/persisted deliveryContext,
// cron must not rebuild delivery.to from the lowercased session-key fragment.
const sessionKey = buildAgentPeerSessionKey({
agentId: "main",
channel: "line",
peerKind: "group",
peerId: "Cabcdef0123456789abcdef0123456789",
});
expect(sessionKey).toBe("agent:main:line:group:cabcdef0123456789abcdef0123456789");
const delivery = await executeAddAndReadDelivery({
callId: "call-line-group-no-context-81628",
agentSessionKey: sessionKey,
// Intentionally no currentDeliveryContext.
});
expect(delivery?.to).toBeUndefined();
});
it("does not surface lowercased LINE DM recipients with per-account-channel-peer scope (#81628)", async () => {
const sessionKey = buildAgentPeerSessionKey({
agentId: "main",
channel: "line",
peerKind: "direct",
accountId: "primary",
dmScope: "per-account-channel-peer",
peerId: "Uabcdef0123456789abcdef0123456789",
});
expect(sessionKey).toBe("agent:main:line:primary:direct:uabcdef0123456789abcdef0123456789");
const delivery = await executeAddAndReadDelivery({
callId: "call-line-direct-no-context-81628",
agentSessionKey: sessionKey,
});
expect(delivery?.to).toBeUndefined();
});
it("does not surface lowercased LINE DM recipients with per-peer scope (#81628)", async () => {
const sessionKey = buildAgentPeerSessionKey({
agentId: "main",
channel: "line",
peerKind: "direct",
dmScope: "per-peer",
peerId: "Uabcdef0123456789abcdef0123456789",
});
expect(sessionKey).toBe("agent:main:direct:uabcdef0123456789abcdef0123456789");
const delivery = await executeAddAndReadDelivery({
callId: "call-line-per-peer-no-context-81628",
agentSessionKey: sessionKey,
});
expect(delivery?.to).toBeUndefined();
});
it("does not let current delivery context override explicit delivery targets", async () => {
expect(
await executeAddAndReadDelivery({
callId: "call-explicit-target-wins",
agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org",
currentDeliveryContext: {
channel: "matrix",
to: "room:!AbCdEf1234567890:example.org",
},
delivery: {
mode: "announce",
channel: "telegram",
to: "-100123",
},
}),
).toEqual({
mode: "announce",
channel: "telegram",
to: "-100123",
});
});
it("keeps explicit delivery account and thread while filling target from context", async () => {
expect(
await executeAddAndReadDelivery({
callId: "call-explicit-delivery-fields-win",
agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org",
currentDeliveryContext: {
channel: "matrix",
to: "!AbCdEf1234567890:example.org",
accountId: "context-bot",
threadId: "$ContextThread:Example.Org",
},
delivery: {
mode: "announce",
accountId: "explicit-bot",
threadId: "$ExplicitThread:Example.Org",
},
}),
).toEqual({
mode: "announce",
channel: "matrix",
to: "!AbCdEf1234567890:example.org",
accountId: "explicit-bot",
threadId: "$ExplicitThread:Example.Org",
});
});
it("trims current context fields without changing provider target casing", async () => {
expect(
await executeAddAndReadDelivery({
callId: "call-trim-current-context",
agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org",
currentDeliveryContext: {
channel: " Matrix ",
to: " !AbCdEf1234567890:Example.Org ",
accountId: " Bot-A ",
threadId: " $RootEvent:Example.Org ",
},
}),
).toEqual({
mode: "announce",
channel: "matrix",
to: "!AbCdEf1234567890:Example.Org",
accountId: "bot-a",
threadId: "$RootEvent:Example.Org",
});
});
it("infers delivery from current context even when no session key is available", async () => {
expect(
await executeAddAndReadDelivery({
callId: "call-context-no-session",
currentDeliveryContext: {
channel: "matrix",
to: "!AbCdEf1234567890:example.org",
},
}),
).toEqual({
mode: "announce",
channel: "matrix",
to: "!AbCdEf1234567890:example.org",
});
});
it("uses current delivery context when delivery is null", async () => {
expect(
await executeAddAndReadDelivery({
callId: "call-null-delivery-current-context",
agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org",
currentDeliveryContext: {
channel: "matrix",
to: "!AbCdEf1234567890:example.org",
},
delivery: null,
}),
).toEqual({
mode: "announce",
channel: "matrix",
to: "!AbCdEf1234567890:example.org",
});
});
it("falls back to stored delivery context when current context has no target", async () => {
extractDeliveryInfoMock.mockReturnValueOnce({
deliveryContext: {
channel: "telegram",
to: "-1001234567890",
},
threadId: "99",
});
expect(
await executeAddAndReadDelivery({
callId: "call-empty-current-context",
agentSessionKey: "agent:main:telegram:group:-1001234567890:topic:99",
currentDeliveryContext: {
channel: "matrix",
to: " ",
},
}),
).toEqual({
mode: "announce",
channel: "telegram",
to: "-1001234567890",
threadId: "99",
});
});
it("does not infer current delivery context when delivery mode is none", async () => {
expect(
await executeAddAndReadDelivery({
callId: "call-current-context-mode-none",
agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org",
currentDeliveryContext: {
channel: "matrix",
to: "!AbCdEf1234567890:example.org",
},
delivery: { mode: "none" },
}),
).toEqual({ mode: "none" });
});
it("infers delivery when delivery is null", async () => {
extractDeliveryInfoMock.mockReturnValueOnce({
deliveryContext: {
to: "alice",
},
threadId: undefined,
});
expect(
await executeAddAndReadDelivery({
callId: "call-null-delivery",
agentSessionKey: "agent:main:dm:alice",
delivery: null,
}),
).toEqual({
mode: "announce",
to: "alice",
});
});
// ── Flat-params recovery (issue #11310) ──────────────────────────────
it("recovers flat params when job is missing", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-flat", {
action: "add",
name: "flat-job",
schedule: { kind: "at", at: new Date(123).toISOString() },
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "do stuff" },
});
const params = expectSingleGatewayCallMethod("cron.add") as
| { name?: string; sessionTarget?: string; payload?: { kind?: string } }
| undefined;
expect(params?.name).toBe("flat-job");
expect(params?.sessionTarget).toBe("isolated");
expect(params?.payload?.kind).toBe("agentTurn");
});
it("recovers flat params when job is empty object", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-empty-job", {
action: "add",
job: {},
name: "empty-job",
schedule: { kind: "cron", expr: "0 9 * * *" },
sessionTarget: "main",
payload: { kind: "systemEvent", text: "wake up" },
});
const params = expectSingleGatewayCallMethod("cron.add") as
| { name?: string; sessionTarget?: string; payload?: { text?: string } }
| undefined;
expect(params?.name).toBe("empty-job");
expect(params?.sessionTarget).toBe("main");
expect(params?.payload?.text).toBe("wake up");
});
it("recovers flat message shorthand as agentTurn payload", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-msg-shorthand", {
action: "add",
schedule: { kind: "at", at: new Date(456).toISOString() },
message: "do stuff",
});
const params = expectSingleGatewayCallMethod("cron.add") as
| { payload?: { kind?: string; message?: string }; sessionTarget?: string }
| undefined;
// normalizeCronJobCreate infers agentTurn from message and isolated from agentTurn
expect(params?.payload?.kind).toBe("agentTurn");
expect(params?.payload?.message).toBe("do stuff");
expect(params?.sessionTarget).toBe("isolated");
});
it("recovers flat text and toolsAllow as a systemEvent payload", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-flat-system-event-cap", {
action: "add",
name: "flat-system-event",
schedule: { kind: "every", everyMs: 60_000 },
text: "tick",
toolsAllow: [" read ", " cron "],
});
const params = expectSingleGatewayCallMethod("cron.add") as
| { payload?: { kind?: string; text?: string; toolsAllow?: string[] } }
| undefined;
expect(params?.payload).toEqual({
kind: "systemEvent",
text: "tick",
toolsAllow: ["read", "cron"],
});
});
it("does not recover flat params when no meaningful job field is present", async () => {
const tool = createTestCronTool();
await expect(
tool.execute("call-no-signal", {
action: "add",
name: "orphan-name",
enabled: true,
}),
).rejects.toThrow("job required");
});
it("prefers existing non-empty job over flat params", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-nested-wins", {
action: "add",
job: {
name: "nested-job",
schedule: { kind: "at", at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "from nested" },
},
name: "flat-name-should-be-ignored",
});
const call = readGatewayCall();
expect(call?.params?.name).toBe("nested-job");
expect((call?.params?.payload as { text?: string } | undefined)?.text).toBe("from nested");
});
it("does not infer delivery when mode is none", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const delivery = await executeAddAndReadDelivery({
callId: "call-none",
agentSessionKey: "agent:main:discord:dm:buddy",
delivery: { mode: "none" },
});
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({
callId: "call-webhook-explicit",
agentSessionKey: "agent:main:discord:dm:buddy",
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
expect(delivery).toEqual({
mode: "webhook",
to: "https://example.invalid/cron-finished",
});
});
it("fails fast when webhook mode is missing delivery.to", async () => {
const tool = createTestCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
await expect(
tool.execute("call-webhook-missing", {
action: "add",
job: {
...buildReminderAgentTurnJob(),
delivery: { mode: "webhook" },
},
}),
).rejects.toThrow('delivery.mode="webhook" requires delivery.to to be a valid http(s) URL');
expect(callGatewayMock).toHaveBeenCalledTimes(0);
});
it("fails fast when webhook mode uses a non-http URL", async () => {
const tool = createTestCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
await expect(
tool.execute("call-webhook-invalid", {
action: "add",
job: {
...buildReminderAgentTurnJob(),
delivery: { mode: "webhook", to: "ftp://example.invalid/cron-finished" },
},
}),
).rejects.toThrow('delivery.mode="webhook" requires delivery.to to be a valid http(s) URL');
expect(callGatewayMock).toHaveBeenCalledTimes(0);
});
it("recovers flat patch params for update action", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-flat", {
action: "update",
jobId: "job-1",
name: "new-name",
enabled: false,
});
const params = expectSingleGatewayCallMethod("cron.update") as
| { id?: string; patch?: { name?: string; enabled?: boolean } }
| undefined;
expect(params?.id).toBe("job-1");
expect(params?.patch?.name).toBe("new-name");
expect(params?.patch?.enabled).toBe(false);
});
it.each([
["delivery.channel", { channel: " " }],
["delivery.channel", { channel: 123 }],
["delivery.to", { to: " " }],
["delivery.to", { to: {} }],
["delivery.failureDestination.to", { failureDestination: { to: " " } }],
["delivery.failureDestination.to", { failureDestination: { to: false } }],
["delivery.completionDestination.to", { completionDestination: { mode: "webhook", to: " " } }],
])("rejects invalid cron.update %s before gateway normalization", async (field, delivery) => {
const tool = createTestCronTool();
await expect(
tool.execute("call-blank-delivery-update", {
action: "update",
id: "job-blank-delivery",
patch: { delivery },
}),
).rejects.toThrow(`${field} must be a non-empty string`);
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("passes nullable cron.update delivery clears through to the gateway", async () => {
const tool = createTestCronTool();
await tool.execute("call-null-delivery-update", {
action: "update",
id: "job-clear-delivery",
patch: {
delivery: {
channel: null,
to: null,
failureDestination: null,
completionDestination: null,
},
},
});
const params = expectSingleGatewayCallMethod("cron.update") as
| { id?: string; patch?: { delivery?: unknown } }
| undefined;
expect(params).toEqual({
id: "job-clear-delivery",
patch: {
delivery: {
channel: null,
to: null,
failureDestination: null,
completionDestination: null,
},
},
});
});
it("rejects agentId retargeting on update", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-update-agent-id", {
action: "update",
id: "job-1",
patch: { agentId: "worker" },
}),
).rejects.toThrow("automation patch agentId cannot be changed");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("allows unscoped operator cron.update agentId retargeting", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-unscoped-update-agent-id", {
action: "update",
id: "job-1",
patch: { agentId: "worker" },
});
const params = expectSingleGatewayCallMethod("cron.update") as
| { id?: string; patch?: { agentId?: string } }
| undefined;
expect(params).toEqual({
id: "job-1",
patch: { agentId: "worker" },
});
});
it("rejects foreign sessionTarget retargeting on update", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-update-session-target", {
action: "update",
id: "job-1",
patch: { sessionTarget: "session:agent:worker:telegram:direct:alice" },
}),
).rejects.toThrow("automations sessionTarget must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("recovers additional flat patch params for update action", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-flat-extra", {
action: "update",
id: "job-2",
sessionTarget: "main",
failureAlert: { after: 3, cooldownMs: 60_000 },
});
const params = expectSingleGatewayCallMethod("cron.update") as
| {
id?: string;
patch?: {
sessionTarget?: string;
failureAlert?: { after?: number; cooldownMs?: number };
};
}
| undefined;
expect(params?.id).toBe("job-2");
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.each([
["canonical", "command"],
["mixed-case", "Command"],
])("rejects %s command payloads from the agent cron tool on update", async (_case, kind) => {
const tool = createTestCronTool();
await expect(
tool.execute("call-command-update", {
action: "update",
id: "job-4",
patch: {
payload: { kind, argv: ["sh", "-lc", "echo ok"] },
},
}),
).rejects.toThrow("automation command payloads cannot be created or edited");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects kind-less edits to stored command payloads", async () => {
callGatewayMock.mockResolvedValueOnce({
id: "job-command",
trigger: { script: "json({ fire: true })" },
payload: { kind: "command", argv: ["echo", "before"] },
});
const tool = createTestCronTool();
await expect(
tool.execute("call-kindless-command-update", {
action: "update",
id: "job-command",
patch: {
payload: { argv: ["sh", "-lc", "echo bypass"] },
},
}),
).rejects.toThrow("automation command payloads cannot be created or edited");
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(readGatewayCall()).toEqual({
method: "cron.get",
params: { id: "job-command" },
});
});
it("allows non-payload updates to triggered command jobs", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({ creatorToolAllowlist: ["read", "cron"] });
await tool.execute("call-command-disable", {
action: "update",
id: "job-command",
patch: { enabled: false },
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(readGatewayCall()).toEqual({
method: "cron.update",
params: {
id: "job-command",
patch: { enabled: false },
},
});
});
it("rejects on-exit schedules from the agent cron tool on update", async () => {
const tool = createTestCronTool();
await expect(
tool.execute("call-on-exit-update", {
action: "update",
id: "job-4",
patch: {
schedule: { kind: "on-exit", command: "make" },
},
}),
).rejects.toThrow("automation on-exit schedules cannot be created or edited");
expect(callGatewayMock).not.toHaveBeenCalled();
});
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({
id: "job-5",
configRevision: "sha256:model-only",
payload: { kind: "agentTurn", message: "before" },
})
.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 = readGatewayCall(1).params 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("recovers a flattened toolsAllow-only systemEvent patch", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-flat-system-event-cap",
payload: { kind: "systemEvent", text: "before", toolsAllow: ["read"] },
})
.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-flat-system-event-cap", {
action: "update",
id: "job-flat-system-event-cap",
toolsAllow: [" cron "],
});
expect(readGatewayCall(1)).toEqual({
method: "cron.update",
params: {
id: "job-flat-system-event-cap",
expectedConfigRevision: "sha256:test",
patch: { payload: { kind: "systemEvent", toolsAllow: ["cron"] } },
},
});
});
it("recovers concatenated cron update keys from local tool-call parsers", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-concatenated", {
action: "update",
id: "job-concat",
patch: {
namePayload: { kind: "agentTurn", message: "Updated prompt.", timeoutSeconds: 20 },
scheduleKind: { everyMs: 60_000, kind: "every" },
sessionTargetName: "updated-name",
},
});
const params = expectSingleGatewayCallMethod("cron.update") as
| {
id?: string;
patch?: {
name?: string;
payload?: { kind?: string; message?: string; timeoutSeconds?: number };
schedule?: { kind?: string; everyMs?: number };
};
}
| undefined;
expect(params?.id).toBe("job-concat");
expect(params?.patch).toEqual({
name: "updated-name",
payload: { kind: "agentTurn", message: "Updated prompt.", timeoutSeconds: 20 },
schedule: { everyMs: 60_000, kind: "every" },
});
});
it("recovers flat concatenated cron update keys from local tool-call parsers", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-flat-concatenated", {
action: "update",
id: "job-concat",
namePayload: { kind: "agentTurn", message: "Updated prompt.", timeoutSeconds: 20 },
scheduleKind: { everyMs: 60_000, kind: "every" },
sessionTargetName: "updated-name",
});
const params = expectSingleGatewayCallMethod("cron.update") as
| {
id?: string;
patch?: {
name?: string;
payload?: { kind?: string; message?: string; timeoutSeconds?: number };
schedule?: { kind?: string; everyMs?: number };
};
}
| undefined;
expect(params?.id).toBe("job-concat");
expect(params?.patch).toEqual({
name: "updated-name",
payload: { kind: "agentTurn", message: "Updated prompt.", timeoutSeconds: 20 },
schedule: { everyMs: 60_000, kind: "every" },
});
});
it("uses flat string scheduleKind without leaking it to cron update", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-string-schedule-kind", {
action: "update",
id: "job-kind",
expr: "0 8 * * *",
scheduleKind: "cron",
});
const params = expectSingleGatewayCallMethod("cron.update") as
| {
id?: string;
patch?: { schedule?: { kind?: string; expr?: string }; scheduleKind?: unknown };
}
| undefined;
expect(params?.id).toBe("job-kind");
expect(params?.patch).toEqual({ schedule: { expr: "0 8 * * *", kind: "cron" } });
expect(params?.patch?.scheduleKind).toBeUndefined();
});
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({
id: "job-7",
payload: { kind: "agentTurn", message: "before" },
})
.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 = readGatewayCall(1).params 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({
id: "job-8",
payload: { kind: "agentTurn", message: "before" },
})
.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-clear-tools", {
action: "update",
id: "job-8",
patch: {
payload: {
toolsAllow: null,
},
},
});
const params = readGatewayCall(1).params 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,
});
});
it("resolves toolsAllow-only patches from existing systemEvent payloads", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-system-event",
payload: { kind: "systemEvent", text: "before", toolsAllow: ["read"] },
})
.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-system-event-tools", {
action: "update",
id: "job-system-event",
patch: { payload: { toolsAllow: ["cron"] } },
});
expect(readGatewayCall(1)).toEqual({
method: "cron.update",
params: {
id: "job-system-event",
expectedConfigRevision: "sha256:test",
patch: { payload: { kind: "systemEvent", toolsAllow: ["cron"] } },
},
});
});
it("caps agentTurn update toolsAllow to the creator tool surface", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-7",
payload: { kind: "agentTurn", message: "before" },
})
.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
});
await tool.execute("call-update-capped-tools", {
action: "update",
id: "job-7",
patch: {
payload: {
toolsAllow: [" exec ", " read "],
},
},
});
const params = readGatewayCall(1).params as
| {
id?: string;
patch?: {
payload?: {
kind?: string;
toolsAllow?: string[];
};
};
}
| undefined;
expect(params?.patch?.payload).toEqual({
kind: "agentTurn",
toolsAllow: ["read"],
});
});
it("keeps the creator tool surface when an agentTurn update clears toolsAllow", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-8",
payload: { kind: "agentTurn", message: "before" },
})
.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
});
await tool.execute("call-update-capped-tools-clear", {
action: "update",
id: "job-8",
patch: {
payload: {
toolsAllow: null,
},
},
});
const params = readGatewayCall(1).params as
| {
patch?: {
payload?: {
kind?: string;
toolsAllow?: string[];
};
};
}
| undefined;
expect(params?.patch?.payload).toEqual({
kind: "agentTurn",
toolsAllow: ["read", "automations"],
toolsAllowIsDefault: true,
});
});
it("preserves legacy authority when updating an agentTurn without a policy patch", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
});
await tool.execute("call-update-capped-no-payload", {
action: "update",
id: "job-9",
patch: { enabled: false },
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(readGatewayCall()).toEqual({
method: "cron.update",
params: {
id: "job-9",
patch: { enabled: false },
},
});
});
it("keeps payload metadata updates offline and preserves the stored cap", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-metadata",
configRevision: "sha256:metadata",
payload: {
kind: "agentTurn",
message: "before",
toolsAllow: ["read", "configured__lookup"],
toolsAllowIsDefault: true,
},
})
.mockResolvedValueOnce({ ok: true });
const resolveCreatorToolAuthority = vi.fn(async () => {
throw new Error("metadata update must stay offline");
});
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
resolveCreatorToolAuthority,
});
await tool.execute("call-update-metadata-offline", {
action: "update",
id: "job-metadata",
patch: { payload: { kind: "agentTurn", message: "after" } },
});
expect(resolveCreatorToolAuthority).not.toHaveBeenCalled();
expect(readGatewayCall(1)).toEqual({
method: "cron.update",
params: {
id: "job-metadata",
expectedConfigRevision: "sha256:metadata",
patch: { payload: { kind: "agentTurn", message: "after" } },
},
});
});
it("intersects a visible finite update offline without opening configured MCP", async () => {
const resolveCreatorToolAuthority = vi.fn(async () => {
throw new Error("visible finite update must stay offline");
});
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
creatorToolAllowlist: ["read", "cron"],
resolveCreatorToolAuthority,
});
await tool.execute("call-update-finite-offline", {
action: "update",
id: "job-finite",
patch: { payload: { kind: "agentTurn", toolsAllow: ["read"] } },
});
expect(resolveCreatorToolAuthority).not.toHaveBeenCalled();
expect(readGatewayCall().params).toMatchObject({
patch: { payload: { kind: "agentTurn", toolsAllow: ["read"] } },
});
});
it("reuses one resolved snapshot across a conflicting wildcard reauthorization", async () => {
const conflict = Object.assign(new Error("changed"), {
name: "GatewayClientRequestError",
details: { code: "CRON_JOB_CHANGED" },
});
const writeIdentities: unknown[] = [];
const authorityScope = createCronCreatorAuthorityRunScope("run-update-race");
const operation = new AbortController();
const authorityGrant = mintCronCreatorAuthorityGrant(authorityScope, operation.signal);
callGatewayMock
.mockResolvedValueOnce({
id: "job-resolve-race",
configRevision: "sha256:first",
payload: { kind: "agentTurn", message: "before", toolsAllow: ["read"] },
})
.mockImplementationOnce(async () => {
writeIdentities.push(getGatewayToolCallerIdentity());
throw conflict;
})
.mockResolvedValueOnce({
id: "job-resolve-race",
configRevision: "sha256:second",
payload: { kind: "agentTurn", message: "before", toolsAllow: [] },
})
.mockImplementationOnce(async () => {
const identity = getGatewayToolCallerIdentity();
writeIdentities.push(identity);
consumeCronCreatorAuthorityGrant(identity!.cronCreatorAuthorityGrant!);
return { ok: true };
});
const resolveCreatorToolAuthority = vi.fn(async () =>
resolvedCreatorAuthority(["read", "configured__lookup"], authorityGrant),
);
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
resolveCreatorToolAuthority,
});
await tool.execute(
"call-update-resolve-race",
{
action: "update",
id: "job-resolve-race",
patch: { payload: { toolsAllow: ["*"] } },
},
operation.signal,
);
expect(resolveCreatorToolAuthority).toHaveBeenCalledOnce();
expect(readGatewayCall(1).params).toMatchObject({
patch: {
payload: {
kind: "agentTurn",
toolsAllow: ["read", "configured__lookup"],
toolsAllowIsDefault: true,
},
},
});
expect(readGatewayCall(3).params).toMatchObject({
expectedConfigRevision: "sha256:second",
patch: {
payload: {
kind: "agentTurn",
toolsAllow: ["read", "configured__lookup"],
toolsAllowIsDefault: true,
},
},
});
expect(writeIdentities).toEqual([
expect.objectContaining({
cronToolsAllowCapture: "final-executable-surface",
cronCreatorAuthorityGrant: authorityGrant,
}),
expect.objectContaining({
cronToolsAllowCapture: "final-executable-surface",
cronCreatorAuthorityGrant: authorityGrant,
}),
]);
expect(() => consumeCronCreatorAuthorityGrant(authorityGrant)).toThrow(
"Configured MCP cron authority is no longer active",
);
revokeCronCreatorAuthorityRunScope(authorityScope);
});
it("does not write a freshly resolved update without authenticated grant transport", async () => {
callGatewayMock.mockResolvedValueOnce({
id: "job-no-caller-identity",
configRevision: "sha256:no-caller-identity",
payload: { kind: "agentTurn", message: "before", toolsAllow: ["read"] },
});
const tool = createTestCronTool({
resolveCreatorToolAuthority: async () =>
resolvedCreatorAuthority(["read", "configured__lookup"]),
});
await expect(
tool.execute("call-update-no-caller-identity", {
action: "update",
id: "job-no-caller-identity",
patch: { payload: { toolsAllow: ["*"] } },
}),
).rejects.toThrow("requires an authenticated local agent run");
expect(callGatewayMock).toHaveBeenCalledOnce();
expect(readGatewayCall().method).toBe("cron.get");
});
it("fails a queued configured-MCP wildcard update visibly without writing", async () => {
callGatewayMock.mockResolvedValueOnce({
id: "job-queued-authority",
configRevision: "sha256:queued-authority",
payload: { kind: "agentTurn", message: "before", toolsAllow: ["read"] },
});
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
creatorToolAllowlist: ["read", "cron"],
creatorAuthorityUnavailableReason: "queued-local-operator-configured-mcp",
});
await expect(
tool.execute("call-queued-configured-mcp-update", {
action: "update",
id: "job-queued-authority",
patch: { payload: { toolsAllow: ["*"] } },
}),
).rejects.toThrow("no automation changes were saved");
expect(callGatewayMock).toHaveBeenCalledOnce();
expect(readGatewayCall().method).toBe("cron.get");
});
it("rejects an unknown finite update when configured-MCP capture is incomplete", async () => {
callGatewayMock.mockResolvedValueOnce({
id: "job-incomplete-authority",
configRevision: "sha256:incomplete-authority",
payload: { kind: "agentTurn", message: "before", toolsAllow: ["read"] },
});
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
creatorToolAllowlistCaptureRef: {},
});
await expect(
tool.execute("call-incomplete-authority-update", {
action: "update",
id: "job-incomplete-authority",
patch: { payload: { kind: "agentTurn", toolsAllow: ["future__tool"] } },
}),
).rejects.toThrow("fresh authenticated direct-local operator turn");
expect(callGatewayMock).toHaveBeenCalledOnce();
expect(readGatewayCall()).toEqual({
method: "cron.get",
params: { id: "job-incomplete-authority" },
});
});
it("does not write an update when configured MCP authentication fails", async () => {
callGatewayMock.mockResolvedValueOnce({
id: "job-auth-failure",
configRevision: "sha256:auth-failure",
payload: { kind: "agentTurn", message: "before", toolsAllow: ["read"] },
});
const tool = createTestCronTool({
agentSessionKey: "agent:main:main",
resolveCreatorToolAuthority: async () => {
throw new Error("Sign in to configured MCP, then retry; no automation changes were saved.");
},
});
await expect(
tool.execute("call-update-auth-failure", {
action: "update",
id: "job-auth-failure",
patch: { payload: { toolsAllow: ["*"] } },
}),
).rejects.toThrow("no automation changes were saved");
expect(callGatewayMock).toHaveBeenCalledOnce();
expect(readGatewayCall().method).toBe("cron.get");
});
it("leaves a stored narrower cap untouched when updating without a policy patch", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "exec", "cron"],
});
await tool.execute("call-update-preserve-existing-tools", {
action: "update",
id: "job-10",
patch: { enabled: false },
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(readGatewayCall()).toEqual({
method: "cron.update",
params: {
id: "job-10",
patch: { enabled: false },
},
});
});
it("retries cap derivation after a concurrent cron job update", async () => {
const conflict = Object.assign(
new Error("cron job definition no longer matches the loaded version"),
{
name: "GatewayClientRequestError",
details: {
code: "CRON_JOB_CHANGED",
expectedConfigRevision: "sha256:first",
actualConfigRevision: "sha256:second",
},
},
);
callGatewayMock
.mockResolvedValueOnce({
id: "job-race",
configRevision: "sha256:first",
payload: { kind: "agentTurn", message: "hello", toolsAllow: ["read"] },
})
.mockRejectedValueOnce(conflict)
.mockResolvedValueOnce({
id: "job-race",
configRevision: "sha256:second",
payload: { kind: "agentTurn", message: "hello", toolsAllow: [] },
})
.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
creatorToolAllowlist: ["read", "exec", "cron"],
});
await tool.execute("call-update-retry-cap-race", {
action: "update",
id: "job-race",
patch: { payload: { message: "updated" } },
});
expect(callGatewayMock).toHaveBeenCalledTimes(4);
expect(readGatewayCall(1)).toEqual({
method: "cron.update",
params: {
id: "job-race",
expectedConfigRevision: "sha256:first",
patch: { payload: { kind: "agentTurn", message: "updated" } },
},
});
expect(readGatewayCall(3)).toEqual({
method: "cron.update",
params: {
id: "job-race",
expectedConfigRevision: "sha256:second",
patch: { payload: { kind: "agentTurn", message: "updated" } },
},
});
});
it("fails closed when cron.get omits the update revision", async () => {
callGatewayMock.mockResolvedValueOnce({
id: "job-no-revision",
configRevision: null,
payload: { kind: "agentTurn", message: "hello", toolsAllow: ["read"] },
});
const tool = createTestCronTool({ creatorToolAllowlist: ["read", "cron"] });
await expect(
tool.execute("call-update-no-revision", {
action: "update",
id: "job-no-revision",
patch: { payload: { message: "updated" } },
}),
).rejects.toThrow("cron.get response is missing configRevision");
expect(callGatewayMock).toHaveBeenCalledTimes(1);
});
it("preserves an existing narrower toolsAllow when updating payload fields without toolsAllow", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-11",
payload: { kind: "agentTurn", message: "hello", toolsAllow: ["read"] },
})
.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "exec", "cron"],
});
await tool.execute("call-update-preserve-existing-payload-tools", {
action: "update",
id: "job-11",
patch: {
payload: { model: "openai/gpt-5.5" },
},
});
expect(callGatewayMock).toHaveBeenCalledTimes(2);
expect(readGatewayCall(1)).toEqual({
method: "cron.update",
params: {
id: "job-11",
expectedConfigRevision: "sha256:test",
patch: {
payload: {
kind: "agentTurn",
model: "openai/gpt-5.5",
},
},
},
});
});
it("leaves a stored default cap untouched across a non-policy update", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
});
await tool.execute("call-update-preserve-default-flag", {
action: "update",
id: "job-13",
patch: { enabled: false },
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(readGatewayCall()).toEqual({
method: "cron.update",
params: {
id: "job-13",
patch: { enabled: false },
},
});
});
it("adds the creator tool surface when converting an existing job to agentTurn", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-12",
payload: { kind: "systemEvent", text: "hello" },
})
.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
});
await tool.execute("call-update-convert-capped-agent-turn", {
action: "update",
id: "job-12",
patch: {
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "run later" },
},
});
expect(callGatewayMock).toHaveBeenCalledTimes(2);
expect(readGatewayCall(1)).toEqual({
method: "cron.update",
params: {
id: "job-12",
expectedConfigRevision: "sha256:test",
patch: {
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: "run later",
toolsAllow: ["read", "automations"],
toolsAllowIsDefault: true,
},
},
},
});
});
it("preserves null model payload patches on update", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-clear-model", {
action: "update",
id: "job-9",
patch: {
payload: {
model: null,
},
},
});
const params = expectSingleGatewayCallMethod("cron.update") as
| {
id?: string;
patch?: {
payload?: {
kind?: string;
model?: string | null;
};
};
}
| undefined;
expect(params?.id).toBe("job-9");
expect(params?.patch?.payload).toEqual({
kind: "agentTurn",
model: null,
});
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */