import { describe, expect, it, vi } from "vitest"; import { wrapToolWithBeforeToolCallHook } from "../agents/agent-tools.before-tool-call.js"; import { BEFORE_TOOL_CALL_HOOK_CONTEXT } from "../agents/before-tool-call-metadata.js"; import type { CodeModeHeadlessResult } from "../agents/code-mode.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createCronScriptRuntime } from "./trigger-script.js"; type EvaluatorDeps = Parameters[0]; type HeadlessParams = Parameters>[0]; type PrepareParams = Parameters>[0]; const beforeToolCallTesting = { BEFORE_TOOL_CALL_HOOK_CONTEXT }; function completed(params: { value: unknown; output?: unknown[] }): CodeModeHeadlessResult { return { status: "completed", value: params.value, output: params.output ?? [], toolCallCount: 0, }; } function abortReason(signal: AbortSignal | undefined): Error { const reason: unknown = signal?.reason; return reason instanceof Error ? reason : new Error("preparation aborted"); } function createPreparedRuntime(config: OpenClawConfig) { const tool = wrapToolWithBeforeToolCallHook( { name: "probe", label: "Probe", description: "Probe tool", parameters: { type: "object", properties: {} }, execute: vi.fn(), } satisfies AnyAgentTool, { config, agentId: "main", sessionKey: "cron:test:trigger" }, ); return { tools: [tool], ctx: { config, runtimeConfig: config, agentId: "main", sessionKey: "cron:test:trigger", }, hookContext: { config, agentId: "main", sessionKey: "cron:test:trigger" }, }; } function createEvaluator( runHeadless: ( params: Parameters< NonNullable[0]["runHeadless"]> >[0], ) => Promise, ) { const config = {} as OpenClawConfig; const prepareRuntime = vi.fn(async () => createPreparedRuntime(config)); return { evaluate: createCronScriptRuntime({ config, runHeadless, prepareRuntime }).evaluateTrigger, prepareRuntime, }; } function createCronTriggerEvaluator(deps: EvaluatorDeps) { return createCronScriptRuntime(deps).evaluateTrigger; } describe("cron trigger script evaluator", () => { it("prefers a valid returned value and injects trigger state", async () => { const runHeadless = vi.fn(async (_params: HeadlessParams) => completed({ value: { fire: true, message: "changed", state: { revision: 2 } }, output: [{ type: "json", value: { fire: false, state: { revision: 1 } } }], }), ); const { evaluate } = createEvaluator(runHeadless); await expect( evaluate({ jobId: "job-value", script: "return result", state: { revision: 1 }, }), ).resolves.toEqual({ kind: "evaluated", fire: true, message: "changed", state: { revision: 2 }, }); expect(runHeadless).toHaveBeenCalledOnce(); expect(runHeadless).toHaveBeenCalledWith( expect.objectContaining({ extraNamespaces: [ { id: "cron:trigger", globalName: "trigger", scope: { kind: "object", entries: [["state", { kind: "value", value: { revision: 1 } }]], }, }, ], }), ); }); it("injects the current stream batch beside trigger state", async () => { const runHeadless = vi.fn(async () => completed({ value: { fire: false } })); const { evaluate } = createEvaluator(runHeadless); await evaluate({ jobId: "job-stream", script: "return result", state: { cursor: 2 }, streamBatch: "line one\nline two", }); expect(runHeadless).toHaveBeenCalledWith( expect.objectContaining({ extraNamespaces: [ expect.objectContaining({ globalName: "trigger", scope: { kind: "object", entries: [ ["state", { kind: "value", value: { cursor: 2 } }], ["streamBatch", { kind: "value", value: "line one\nline two" }], ], }, }), ], }), ); }); it("falls back to the last json output entry when the returned object is not a trigger result", async () => { const { evaluate } = createEvaluator( vi.fn(async () => completed({ value: { ignored: true }, output: [ { type: "json", value: { fire: false, state: { old: true } } }, { type: "text", text: "ignored" }, { type: "json", value: { fire: true, state: { current: true } } }, ], }), ), ); await expect( evaluate({ jobId: "job-json", script: "json(result)", state: null }), ).resolves.toEqual({ kind: "evaluated", fire: true, state: { current: true }, }); }); it("uses a fresh hook run scope for each evaluation", async () => { const contexts: Array> = []; const { evaluate, prepareRuntime } = createEvaluator( vi.fn(async (params) => { const wrapped = params.ctx.catalogRef?.current?.entries[0]?.tool; contexts.push((wrapped ?? {}) as Record); return completed({ value: { fire: false } }); }), ); await evaluate({ jobId: "job-loop-scope", script: "return result", state: null }); await evaluate({ jobId: "job-loop-scope", script: "return result", state: null }); expect(prepareRuntime).toHaveBeenCalledOnce(); const runIds = contexts.map((tool) => { const context = tool[beforeToolCallTesting.BEFORE_TOOL_CALL_HOOK_CONTEXT]; return (context as { runId?: string } | undefined)?.runId; }); expect(runIds[0]).toMatch(/^cron-trigger:job-loop-scope:/); expect(runIds[1]).toMatch(/^cron-trigger:job-loop-scope:/); expect(runIds[1]).not.toBe(runIds[0]); }); it("single-flights concurrent runtime preparation for the same job", async () => { const config = {} as OpenClawConfig; let release: ((runtime: ReturnType) => void) | undefined; const pending = new Promise>((resolve) => { release = resolve; }); const prepareRuntime = vi.fn(async () => await pending); const runHeadless = vi.fn(async () => completed({ value: { fire: false } })); const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless }); const first = evaluate({ jobId: "job-single-flight", script: "return result", state: null }); const second = evaluate({ jobId: "job-single-flight", script: "return result", state: null }); await vi.waitFor(() => expect(prepareRuntime).toHaveBeenCalledOnce()); release?.(createPreparedRuntime(config)); await expect(Promise.all([first, second])).resolves.toEqual([ { kind: "evaluated", fire: false }, { kind: "evaluated", fire: false }, ]); expect(runHeadless).toHaveBeenCalledTimes(2); }); it("retries shared runtime preparation for a still-live evaluator after its owner aborts", async () => { const config = {} as OpenClawConfig; const prepareRuntime = vi.fn(async (params: PrepareParams) => { if (prepareRuntime.mock.calls.length === 1) { return await new Promise((_resolve, reject) => { params.signal?.addEventListener("abort", () => reject(abortReason(params.signal)), { once: true, }); }); } return createPreparedRuntime(config); }); const runHeadless = vi.fn(async () => completed({ value: { fire: false } })); const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless }); const controller = new AbortController(); const first = evaluate({ jobId: "job-shared-abort", script: "return result", state: null, abortSignal: controller.signal, }); const second = evaluate({ jobId: "job-shared-abort", script: "return result", state: null, }); await vi.waitFor(() => expect(prepareRuntime).toHaveBeenCalledOnce()); controller.abort(); await expect(first).resolves.toMatchObject({ kind: "error", code: "aborted" }); await expect(second).resolves.toEqual({ kind: "evaluated", fire: false }); expect(prepareRuntime).toHaveBeenCalledTimes(2); expect(runHeadless).toHaveBeenCalledOnce(); }); it("retries shared runtime preparation after an earlier evaluator reaches its deadline", async () => { vi.useFakeTimers(); try { const config = {} as OpenClawConfig; const prepareRuntime = vi.fn(async (params: PrepareParams) => { if (prepareRuntime.mock.calls.length === 1) { return await new Promise((_resolve, reject) => { params.signal?.addEventListener("abort", () => reject(abortReason(params.signal)), { once: true, }); }); } return createPreparedRuntime(config); }); const runHeadless = vi.fn(async () => completed({ value: { fire: false } })); const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless }); const first = evaluate({ jobId: "job-shared-timeout", script: "return result", state: null, }); await vi.waitFor(() => expect(prepareRuntime).toHaveBeenCalledOnce()); await vi.advanceTimersByTimeAsync(1_000); const second = evaluate({ jobId: "job-shared-timeout", script: "return result", state: null, }); await vi.advanceTimersByTimeAsync(29_000); await expect(first).resolves.toMatchObject({ kind: "error", code: "timeout" }); await expect(second).resolves.toEqual({ kind: "evaluated", fire: false }); expect(prepareRuntime).toHaveBeenCalledTimes(2); expect(runHeadless).toHaveBeenCalledOnce(); } finally { vi.useRealTimers(); } }); it("invalidates a cached runtime when toolsAllow changes", async () => { const config = {} as OpenClawConfig; const prepareRuntime = vi.fn(async (_params: PrepareParams) => createPreparedRuntime(config)); const runHeadless = vi.fn(async () => completed({ value: { fire: false } })); const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless }); await evaluate({ jobId: "job-tools-allow", script: "return result", state: null, toolsAllow: ["probe"], }); await evaluate({ jobId: "job-tools-allow", script: "return result", state: null, toolsAllow: ["exec"], }); expect(prepareRuntime).toHaveBeenCalledTimes(2); expect(prepareRuntime.mock.calls.map(([params]) => params.toolsAllow)).toEqual([ ["probe"], ["exec"], ]); }); it("forwards scheduled provenance and invalidates cached authority when it changes", async () => { const config = {} as OpenClawConfig; const prepareRuntime = vi.fn(async (_params: PrepareParams) => createPreparedRuntime(config)); const runHeadless = vi.fn(async () => completed({ value: { fire: false } })); const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless }); for (const [ownerSessionKey, ownerAccountId] of [ ["agent:main:discord:group:a", "alpha"], ["agent:main:discord:group:b", "beta"], ] as const) { await evaluate({ jobId: "job-owner-session", script: "return result", state: null, toolsAllow: ["write"], scheduledToolPolicy: { version: 1, mode: "account", ownerSessionKey, ownerAccountId, }, }); } expect(prepareRuntime.mock.calls.map(([params]) => params.scheduledToolPolicy)).toEqual([ { version: 1, mode: "account", ownerSessionKey: "agent:main:discord:group:a", ownerAccountId: "alpha", }, { version: 1, mode: "account", ownerSessionKey: "agent:main:discord:group:b", ownerAccountId: "beta", }, ]); }); it.each([ completed({ value: null }), completed({ value: { fire: "yes" } }), completed({ value: { fire: true, message: 42 } }), ])("rejects invalid result shapes", async (headlessResult) => { const { evaluate } = createEvaluator(vi.fn(async () => headlessResult)); const result = await evaluate({ jobId: "job-invalid", script: "return bad", state: null }); expect(result).toMatchObject({ kind: "error", code: "internal_error" }); }); it("rejects returned state larger than 16KB", async () => { const { evaluate } = createEvaluator( vi.fn(async () => completed({ value: { fire: false, state: { value: "x".repeat(16 * 1024) } } }), ), ); await expect( evaluate({ jobId: "job-large-state", script: "return result", state: null }), ).resolves.toEqual({ kind: "error", code: "output_limit_exceeded", error: "cron trigger state exceeds the 16KB limit", }); }); it("returns busy instead of queueing a fourth concurrent evaluation", async () => { let release: ((result: CodeModeHeadlessResult) => void) | undefined; const pending = new Promise((resolve) => { release = resolve; }); const runHeadless = vi.fn(async () => await pending); const { evaluate } = createEvaluator(runHeadless); const running = ["one", "two", "three"].map((jobId) => evaluate({ jobId, script: "return result", state: null }), ); await vi.waitFor(() => expect(runHeadless).toHaveBeenCalledTimes(3)); const saturated = await evaluate({ jobId: "four", script: "return result", state: null, }); release?.(completed({ value: { fire: false } })); await Promise.all(running); expect(saturated).toEqual({ kind: "busy" }); expect(runHeadless).toHaveBeenCalledTimes(3); }); it("cancels runtime preparation when its only evaluator aborts", async () => { const config = {} as OpenClawConfig; let preparationSignal: AbortSignal | undefined; const prepareRuntime = vi.fn(async (params: { signal?: AbortSignal }): Promise => { preparationSignal = params.signal; return await new Promise((_resolve, reject) => { params.signal?.addEventListener( "abort", () => { const reason = params.signal?.reason; reject(reason instanceof Error ? reason : new Error("aborted")); }, { once: true }, ); }); }); const runHeadless = vi.fn(async () => completed({ value: { fire: false } })); const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless }); const controller = new AbortController(); const evaluation = evaluate({ jobId: "job-abort-preparation", script: "return result", state: null, abortSignal: controller.signal, }); await vi.waitFor(() => expect(prepareRuntime).toHaveBeenCalledOnce()); controller.abort(); await expect(evaluation).resolves.toMatchObject({ kind: "error", code: "aborted", error: "cron trigger evaluation aborted", }); await vi.waitFor(() => expect(preparationSignal?.aborted).toBe(true)); expect(runHeadless).not.toHaveBeenCalled(); }); it("keeps the internal evaluation deadline classified as timeout", async () => { vi.useFakeTimers(); try { const config = {} as OpenClawConfig; const prepareRuntime = vi.fn(async (params: { signal?: AbortSignal }): Promise => { return await new Promise((_resolve, reject) => { params.signal?.addEventListener("abort", () => reject(abortReason(params.signal)), { once: true, }); }); }); const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless: vi.fn(async () => completed({ value: { fire: false } })), }); const evaluation = evaluate({ jobId: "job-preparation-timeout", script: "return result", state: null, }); await vi.advanceTimersByTimeAsync(30_000); await expect(evaluation).resolves.toMatchObject({ kind: "error", code: "timeout", error: "cron trigger evaluation timed out", }); } finally { vi.useRealTimers(); } }); }); describe("cron script payload evaluator", () => { it("exposes a stream batch beside the script payload state", async () => { const config = {} as OpenClawConfig; const runHeadless = vi.fn(async (_params: HeadlessParams) => completed({ value: {} })); const runtime = createCronScriptRuntime({ config, runHeadless, prepareRuntime: vi.fn(async () => createPreparedRuntime(config)), }); await runtime.executePayload({ jobId: "payload-stream", script: "return {}", state: { cursor: 3 }, streamBatch: "line one\nline two", }); expect(runHeadless).toHaveBeenCalledWith( expect.objectContaining({ extraNamespaces: [ expect.objectContaining({ globalName: "trigger", scope: { kind: "object", entries: [ ["state", { kind: "value", value: { cursor: 3 } }], ["streamBatch", { kind: "value", value: "line one\nline two" }], ], }, }), ], }), ); }); it("uses payload-grade capped budgets and exposes frozen trigger state", async () => { const config = {} as OpenClawConfig; const runHeadless = vi.fn(async (_params: HeadlessParams) => completed({ value: { notify: "queue changed", wake: "now", state: { revision: 2 }, nextCheck: "5m", }, }), ); const runtime = createCronScriptRuntime({ config, runHeadless, prepareRuntime: vi.fn(async () => createPreparedRuntime(config)), }); await expect( runtime.executePayload({ jobId: "payload-job", script: "return result", state: { revision: 1 }, timeoutSeconds: 10_000, toolBudget: 10_000, }), ).resolves.toEqual({ kind: "completed", notify: "queue changed", wake: "now", stateChanged: true, state: { revision: 2 }, nextCheck: { delayMs: 300_000 }, }); expect(runHeadless).toHaveBeenCalledWith( expect.objectContaining({ maxToolCalls: 200, extraNamespaces: [ { id: "cron:trigger", globalName: "trigger", scope: { kind: "object", entries: [["state", { kind: "value", value: { revision: 1 } }]], }, }, ], }), ); const headlessParams = runHeadless.mock.calls[0]?.[0]; expect(headlessParams?.wallClockMs).toBeGreaterThanOrEqual(899_000); expect(headlessParams?.wallClockMs).toBeLessThanOrEqual(900_000); }); it("uses payload defaults and accepts an omitted result state", async () => { const config = {} as OpenClawConfig; const runHeadless = vi.fn(async (_params: HeadlessParams) => completed({ value: {} })); const runtime = createCronScriptRuntime({ config, runHeadless, prepareRuntime: vi.fn(async () => createPreparedRuntime(config)), }); await expect( runtime.executePayload({ jobId: "payload-defaults", script: "return {}", state: null }), ).resolves.toEqual({ kind: "completed", stateChanged: false }); expect(runHeadless).toHaveBeenCalledWith(expect.objectContaining({ maxToolCalls: 50 })); const headlessParams = runHeadless.mock.calls[0]?.[0]; expect(headlessParams?.wallClockMs).toBeGreaterThanOrEqual(299_000); expect(headlessParams?.wallClockMs).toBeLessThanOrEqual(300_000); }); it("canonicalizes returned state to the JSON value that will be persisted", async () => { const config = {} as OpenClawConfig; const runtime = createCronScriptRuntime({ config, runHeadless: vi.fn(async () => completed({ value: { state: { keep: 1, dropped: undefined, nonFinite: Number.NaN } }, }), ), prepareRuntime: vi.fn(async () => createPreparedRuntime(config)), }); await expect( runtime.executePayload({ jobId: "payload-json-state", script: "return state", state: null }), ).resolves.toEqual({ kind: "completed", stateChanged: true, state: { keep: 1, nonFinite: null }, }); }); it.each([ [{ notify: 42 }, "notify must be a string"], [{ wake: "later" }, 'wake must be "now" or "next-heartbeat"'], [{ nextCheck: "tomorrowish" }, "nextCheck must be a positive duration"], [{ state: "x".repeat(17 * 1024) }, "state exceeds the 16KB limit"], ] as const)("rejects an invalid result %#", async (value, error) => { const config = {} as OpenClawConfig; const runtime = createCronScriptRuntime({ config, runHeadless: vi.fn(async () => completed({ value })), prepareRuntime: vi.fn(async () => createPreparedRuntime(config)), }); await expect( runtime.executePayload({ jobId: "payload-invalid", script: "return result", state: null }), ).resolves.toMatchObject({ kind: "error", error: expect.stringContaining(error) }); }); it("surfaces executor failures through the cron error contract", async () => { const config = {} as OpenClawConfig; const runtime = createCronScriptRuntime({ config, runHeadless: vi.fn(async () => ({ status: "failed" as const, code: "tool_budget_exceeded" as const, error: "tool budget exceeded", output: [], toolCallCount: 51, })), prepareRuntime: vi.fn(async () => createPreparedRuntime(config)), }); await expect( runtime.executePayload({ jobId: "payload-failed", script: "return result", state: null }), ).resolves.toEqual({ kind: "error", code: "tool_budget_exceeded", error: "tool budget exceeded", }); }); });