diff --git a/src/cron/isolated-agent/run.payload-fallbacks.test.ts b/src/cron/isolated-agent/run.payload-fallbacks.test.ts
new file mode 100644
index 000000000..9250a0176
--- /dev/null
+++ b/src/cron/isolated-agent/run.payload-fallbacks.test.ts
@@ -0,0 +1,294 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { runWithModelFallback } from "../../agents/model-fallback.js";
+
+// ---------- mocks (same pattern as run.skill-filter.test.ts) ----------
+
+const resolveAgentModelFallbacksOverrideMock = vi.fn();
+
+vi.mock("../../agents/agent-scope.js", () => ({
+ resolveAgentConfig: vi.fn(),
+ resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
+ resolveAgentModelFallbacksOverride: resolveAgentModelFallbacksOverrideMock,
+ resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"),
+ resolveDefaultAgentId: vi.fn().mockReturnValue("default"),
+ resolveAgentSkillsFilter: vi.fn().mockReturnValue(undefined),
+}));
+
+vi.mock("../../agents/skills.js", () => ({
+ buildWorkspaceSkillSnapshot: vi.fn().mockReturnValue({
+ prompt: "",
+ resolvedSkills: [],
+ version: 42,
+ }),
+}));
+
+vi.mock("../../agents/skills/refresh.js", () => ({
+ getSkillsSnapshotVersion: vi.fn().mockReturnValue(42),
+}));
+
+vi.mock("../../agents/workspace.js", () => ({
+ ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }),
+}));
+
+vi.mock("../../agents/model-catalog.js", () => ({
+ loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }),
+}));
+
+vi.mock("../../agents/model-selection.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }),
+ isCliProvider: vi.fn().mockReturnValue(false),
+ resolveAllowedModelRef: vi
+ .fn()
+ .mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }),
+ resolveConfiguredModelRef: vi.fn().mockReturnValue({ provider: "openai", model: "gpt-4" }),
+ resolveHooksGmailModel: vi.fn().mockReturnValue(null),
+ resolveThinkingDefault: vi.fn().mockReturnValue(undefined),
+ };
+});
+
+vi.mock("../../agents/model-fallback.js", () => ({
+ runWithModelFallback: vi.fn().mockResolvedValue({
+ result: {
+ payloads: [{ text: "test output" }],
+ meta: { agentMeta: { usage: { input: 10, output: 20 } } },
+ },
+ provider: "openai",
+ model: "gpt-4",
+ }),
+}));
+
+const runWithModelFallbackMock = vi.mocked(runWithModelFallback);
+
+vi.mock("../../agents/pi-embedded.js", () => ({
+ runEmbeddedPiAgent: vi.fn().mockResolvedValue({
+ payloads: [{ text: "test output" }],
+ meta: { agentMeta: { usage: { input: 10, output: 20 } } },
+ }),
+}));
+
+vi.mock("../../agents/context.js", () => ({
+ lookupContextTokens: vi.fn().mockReturnValue(128000),
+}));
+
+vi.mock("../../agents/date-time.js", () => ({
+ formatUserTime: vi.fn().mockReturnValue("2026-02-10 12:00"),
+ resolveUserTimeFormat: vi.fn().mockReturnValue("24h"),
+ resolveUserTimezone: vi.fn().mockReturnValue("UTC"),
+}));
+
+vi.mock("../../agents/timeout.js", () => ({
+ resolveAgentTimeoutMs: vi.fn().mockReturnValue(60_000),
+}));
+
+vi.mock("../../agents/usage.js", () => ({
+ deriveSessionTotalTokens: vi.fn().mockReturnValue(30),
+ hasNonzeroUsage: vi.fn().mockReturnValue(false),
+}));
+
+vi.mock("../../agents/subagent-announce.js", () => ({
+ runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true),
+}));
+
+vi.mock("../../agents/cli-runner.js", () => ({
+ runCliAgent: vi.fn(),
+}));
+
+vi.mock("../../agents/cli-session.js", () => ({
+ getCliSessionId: vi.fn().mockReturnValue(undefined),
+ setCliSessionId: vi.fn(),
+}));
+
+vi.mock("../../auto-reply/thinking.js", () => ({
+ normalizeThinkLevel: vi.fn().mockReturnValue(undefined),
+ normalizeVerboseLevel: vi.fn().mockReturnValue("off"),
+ supportsXHighThinking: vi.fn().mockReturnValue(false),
+}));
+
+vi.mock("../../cli/outbound-send-deps.js", () => ({
+ createOutboundSendDeps: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock("../../config/sessions.js", () => ({
+ resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"),
+ resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"),
+ setSessionRuntimeModel: vi.fn(),
+ updateSessionStore: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("../../routing/session-key.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"),
+ normalizeAgentId: vi.fn((id: string) => id),
+ };
+});
+
+vi.mock("../../infra/agent-events.js", () => ({
+ registerAgentRunContext: vi.fn(),
+}));
+
+vi.mock("../../infra/outbound/deliver.js", () => ({
+ deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("../../infra/skills-remote.js", () => ({
+ getRemoteSkillEligibility: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock("../../logger.js", () => ({
+ logWarn: vi.fn(),
+}));
+
+vi.mock("../../security/external-content.js", () => ({
+ buildSafeExternalPrompt: vi.fn().mockReturnValue("safe prompt"),
+ detectSuspiciousPatterns: vi.fn().mockReturnValue([]),
+ getHookType: vi.fn().mockReturnValue("unknown"),
+ isExternalHookSession: vi.fn().mockReturnValue(false),
+}));
+
+vi.mock("../delivery.js", () => ({
+ resolveCronDeliveryPlan: vi.fn().mockReturnValue({ requested: false }),
+}));
+
+vi.mock("./delivery-target.js", () => ({
+ resolveDeliveryTarget: vi.fn().mockResolvedValue({
+ channel: "discord",
+ to: undefined,
+ accountId: undefined,
+ error: undefined,
+ }),
+}));
+
+vi.mock("./helpers.js", () => ({
+ isHeartbeatOnlyResponse: vi.fn().mockReturnValue(false),
+ pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined),
+ pickLastNonEmptyTextFromPayloads: vi.fn().mockReturnValue("test output"),
+ pickSummaryFromOutput: vi.fn().mockReturnValue("summary"),
+ pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"),
+ resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100),
+}));
+
+const resolveCronSessionMock = vi.fn();
+vi.mock("./session.js", () => ({
+ resolveCronSession: resolveCronSessionMock,
+}));
+
+vi.mock("../../agents/defaults.js", () => ({
+ DEFAULT_CONTEXT_TOKENS: 128000,
+ DEFAULT_MODEL: "gpt-4",
+ DEFAULT_PROVIDER: "openai",
+}));
+
+const { runCronIsolatedAgentTurn } = await import("./run.js");
+
+// ---------- helpers ----------
+
+function makeJob(overrides?: Record) {
+ return {
+ id: "test-job",
+ name: "Test Job",
+ schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
+ sessionTarget: "isolated",
+ payload: { kind: "agentTurn", message: "test" },
+ ...overrides,
+ } as never;
+}
+
+function makeParams(overrides?: Record) {
+ return {
+ cfg: {},
+ deps: {} as never,
+ job: makeJob(overrides?.job ? (overrides.job as Record) : undefined),
+ message: "test",
+ sessionKey: "cron:test",
+ ...overrides,
+ };
+}
+
+// ---------- tests ----------
+
+describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
+ let previousFastTestEnv: string | undefined;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ previousFastTestEnv = process.env.OPENCLAW_TEST_FAST;
+ delete process.env.OPENCLAW_TEST_FAST;
+ resolveAgentModelFallbacksOverrideMock.mockReturnValue(undefined);
+ resolveCronSessionMock.mockReturnValue({
+ storePath: "/tmp/store.json",
+ store: {},
+ sessionEntry: {
+ sessionId: "test-session-id",
+ updatedAt: 0,
+ systemSent: false,
+ skillsSnapshot: undefined,
+ },
+ systemSent: false,
+ isNewSession: true,
+ });
+ });
+
+ afterEach(() => {
+ if (previousFastTestEnv == null) {
+ delete process.env.OPENCLAW_TEST_FAST;
+ return;
+ }
+ process.env.OPENCLAW_TEST_FAST = previousFastTestEnv;
+ });
+
+ it("passes payload.fallbacks as fallbacksOverride when defined", async () => {
+ const result = await runCronIsolatedAgentTurn(
+ makeParams({
+ job: makeJob({
+ payload: {
+ kind: "agentTurn",
+ message: "test",
+ fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"],
+ },
+ }),
+ }),
+ );
+
+ expect(result.status).toBe("ok");
+ expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
+ expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual([
+ "anthropic/claude-sonnet-4-6",
+ "openai/gpt-5",
+ ]);
+ });
+
+ it("falls back to agent-level fallbacks when payload.fallbacks is undefined", async () => {
+ resolveAgentModelFallbacksOverrideMock.mockReturnValue(["openai/gpt-4o"]);
+
+ const result = await runCronIsolatedAgentTurn(
+ makeParams({
+ job: makeJob({ payload: { kind: "agentTurn", message: "test" } }),
+ }),
+ );
+
+ expect(result.status).toBe("ok");
+ expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
+ expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual(["openai/gpt-4o"]);
+ });
+
+ it("payload.fallbacks=[] disables fallbacks even when agent config has them", async () => {
+ resolveAgentModelFallbacksOverrideMock.mockReturnValue(["openai/gpt-4o"]);
+
+ const result = await runCronIsolatedAgentTurn(
+ makeParams({
+ job: makeJob({
+ payload: { kind: "agentTurn", message: "test", fallbacks: [] },
+ }),
+ }),
+ );
+
+ expect(result.status).toBe("ok");
+ expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
+ expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual([]);
+ });
+});
diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts
index b1d958b2a..ff36c5080 100644
--- a/src/cron/isolated-agent/run.ts
+++ b/src/cron/isolated-agent/run.ts
@@ -446,12 +446,18 @@ export async function runCronIsolatedAgentTurn(params: {
verboseLevel: resolvedVerboseLevel,
});
const messageChannel = resolvedDelivery.channel;
+ // Per-job payload.fallbacks takes priority over agent-level fallbacks.
+ const payloadFallbacks =
+ params.job.payload.kind === "agentTurn" && Array.isArray(params.job.payload.fallbacks)
+ ? params.job.payload.fallbacks
+ : undefined;
const fallbackResult = await runWithModelFallback({
cfg: cfgWithAgentDefaults,
provider,
model,
agentDir,
- fallbacksOverride: resolveAgentModelFallbacksOverride(params.cfg, agentId),
+ fallbacksOverride:
+ payloadFallbacks ?? resolveAgentModelFallbacksOverride(params.cfg, agentId),
run: (providerOverride, modelOverride) => {
if (abortSignal?.aborted) {
throw new Error(abortReason());
diff --git a/src/cron/types.ts b/src/cron/types.ts
index 353679449..fc3a89ec6 100644
--- a/src/cron/types.ts
+++ b/src/cron/types.ts
@@ -63,6 +63,8 @@ export type CronPayload =
message: string;
/** Optional model override (provider/model or alias). */
model?: string;
+ /** Optional per-job fallback models; overrides agent/global fallbacks when defined. */
+ fallbacks?: string[];
thinking?: string;
timeoutSeconds?: number;
allowUnsafeExternalContent?: boolean;
@@ -78,6 +80,7 @@ export type CronPayloadPatch =
kind: "agentTurn";
message?: string;
model?: string;
+ fallbacks?: string[];
thinking?: string;
timeoutSeconds?: number;
allowUnsafeExternalContent?: boolean;
diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts
index 7e0ebe549..77238464b 100644
--- a/src/gateway/protocol/schema/cron.ts
+++ b/src/gateway/protocol/schema/cron.ts
@@ -7,6 +7,7 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema }) {
kind: Type.Literal("agentTurn"),
message: params.message,
model: Type.Optional(Type.String()),
+ fallbacks: Type.Optional(Type.Array(Type.String())),
thinking: Type.Optional(Type.String()),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),