Skip to content

Commit f902697

Browse files
authored
feat(cron): add payload.fallbacks for per-job model fallback override (#26120) (#26304)
Co-authored-by: yinghaosang <yinghaosang@users.noreply.github.com>
1 parent 8c98cf0 commit f902697

4 files changed

Lines changed: 305 additions & 1 deletion

File tree

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { runWithModelFallback } from "../../agents/model-fallback.js";
3+
4+
// ---------- mocks (same pattern as run.skill-filter.test.ts) ----------
5+
6+
const resolveAgentModelFallbacksOverrideMock = vi.fn();
7+
8+
vi.mock("../../agents/agent-scope.js", () => ({
9+
resolveAgentConfig: vi.fn(),
10+
resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
11+
resolveAgentModelFallbacksOverride: resolveAgentModelFallbacksOverrideMock,
12+
resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"),
13+
resolveDefaultAgentId: vi.fn().mockReturnValue("default"),
14+
resolveAgentSkillsFilter: vi.fn().mockReturnValue(undefined),
15+
}));
16+
17+
vi.mock("../../agents/skills.js", () => ({
18+
buildWorkspaceSkillSnapshot: vi.fn().mockReturnValue({
19+
prompt: "<available_skills></available_skills>",
20+
resolvedSkills: [],
21+
version: 42,
22+
}),
23+
}));
24+
25+
vi.mock("../../agents/skills/refresh.js", () => ({
26+
getSkillsSnapshotVersion: vi.fn().mockReturnValue(42),
27+
}));
28+
29+
vi.mock("../../agents/workspace.js", () => ({
30+
ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }),
31+
}));
32+
33+
vi.mock("../../agents/model-catalog.js", () => ({
34+
loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }),
35+
}));
36+
37+
vi.mock("../../agents/model-selection.js", async (importOriginal) => {
38+
const actual = await importOriginal<typeof import("../../agents/model-selection.js")>();
39+
return {
40+
...actual,
41+
getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }),
42+
isCliProvider: vi.fn().mockReturnValue(false),
43+
resolveAllowedModelRef: vi
44+
.fn()
45+
.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }),
46+
resolveConfiguredModelRef: vi.fn().mockReturnValue({ provider: "openai", model: "gpt-4" }),
47+
resolveHooksGmailModel: vi.fn().mockReturnValue(null),
48+
resolveThinkingDefault: vi.fn().mockReturnValue(undefined),
49+
};
50+
});
51+
52+
vi.mock("../../agents/model-fallback.js", () => ({
53+
runWithModelFallback: vi.fn().mockResolvedValue({
54+
result: {
55+
payloads: [{ text: "test output" }],
56+
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
57+
},
58+
provider: "openai",
59+
model: "gpt-4",
60+
}),
61+
}));
62+
63+
const runWithModelFallbackMock = vi.mocked(runWithModelFallback);
64+
65+
vi.mock("../../agents/pi-embedded.js", () => ({
66+
runEmbeddedPiAgent: vi.fn().mockResolvedValue({
67+
payloads: [{ text: "test output" }],
68+
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
69+
}),
70+
}));
71+
72+
vi.mock("../../agents/context.js", () => ({
73+
lookupContextTokens: vi.fn().mockReturnValue(128000),
74+
}));
75+
76+
vi.mock("../../agents/date-time.js", () => ({
77+
formatUserTime: vi.fn().mockReturnValue("2026-02-10 12:00"),
78+
resolveUserTimeFormat: vi.fn().mockReturnValue("24h"),
79+
resolveUserTimezone: vi.fn().mockReturnValue("UTC"),
80+
}));
81+
82+
vi.mock("../../agents/timeout.js", () => ({
83+
resolveAgentTimeoutMs: vi.fn().mockReturnValue(60_000),
84+
}));
85+
86+
vi.mock("../../agents/usage.js", () => ({
87+
deriveSessionTotalTokens: vi.fn().mockReturnValue(30),
88+
hasNonzeroUsage: vi.fn().mockReturnValue(false),
89+
}));
90+
91+
vi.mock("../../agents/subagent-announce.js", () => ({
92+
runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true),
93+
}));
94+
95+
vi.mock("../../agents/cli-runner.js", () => ({
96+
runCliAgent: vi.fn(),
97+
}));
98+
99+
vi.mock("../../agents/cli-session.js", () => ({
100+
getCliSessionId: vi.fn().mockReturnValue(undefined),
101+
setCliSessionId: vi.fn(),
102+
}));
103+
104+
vi.mock("../../auto-reply/thinking.js", () => ({
105+
normalizeThinkLevel: vi.fn().mockReturnValue(undefined),
106+
normalizeVerboseLevel: vi.fn().mockReturnValue("off"),
107+
supportsXHighThinking: vi.fn().mockReturnValue(false),
108+
}));
109+
110+
vi.mock("../../cli/outbound-send-deps.js", () => ({
111+
createOutboundSendDeps: vi.fn().mockReturnValue({}),
112+
}));
113+
114+
vi.mock("../../config/sessions.js", () => ({
115+
resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"),
116+
resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"),
117+
setSessionRuntimeModel: vi.fn(),
118+
updateSessionStore: vi.fn().mockResolvedValue(undefined),
119+
}));
120+
121+
vi.mock("../../routing/session-key.js", async (importOriginal) => {
122+
const actual = await importOriginal<typeof import("../../routing/session-key.js")>();
123+
return {
124+
...actual,
125+
buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"),
126+
normalizeAgentId: vi.fn((id: string) => id),
127+
};
128+
});
129+
130+
vi.mock("../../infra/agent-events.js", () => ({
131+
registerAgentRunContext: vi.fn(),
132+
}));
133+
134+
vi.mock("../../infra/outbound/deliver.js", () => ({
135+
deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined),
136+
}));
137+
138+
vi.mock("../../infra/skills-remote.js", () => ({
139+
getRemoteSkillEligibility: vi.fn().mockReturnValue({}),
140+
}));
141+
142+
vi.mock("../../logger.js", () => ({
143+
logWarn: vi.fn(),
144+
}));
145+
146+
vi.mock("../../security/external-content.js", () => ({
147+
buildSafeExternalPrompt: vi.fn().mockReturnValue("safe prompt"),
148+
detectSuspiciousPatterns: vi.fn().mockReturnValue([]),
149+
getHookType: vi.fn().mockReturnValue("unknown"),
150+
isExternalHookSession: vi.fn().mockReturnValue(false),
151+
}));
152+
153+
vi.mock("../delivery.js", () => ({
154+
resolveCronDeliveryPlan: vi.fn().mockReturnValue({ requested: false }),
155+
}));
156+
157+
vi.mock("./delivery-target.js", () => ({
158+
resolveDeliveryTarget: vi.fn().mockResolvedValue({
159+
channel: "discord",
160+
to: undefined,
161+
accountId: undefined,
162+
error: undefined,
163+
}),
164+
}));
165+
166+
vi.mock("./helpers.js", () => ({
167+
isHeartbeatOnlyResponse: vi.fn().mockReturnValue(false),
168+
pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined),
169+
pickLastNonEmptyTextFromPayloads: vi.fn().mockReturnValue("test output"),
170+
pickSummaryFromOutput: vi.fn().mockReturnValue("summary"),
171+
pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"),
172+
resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100),
173+
}));
174+
175+
const resolveCronSessionMock = vi.fn();
176+
vi.mock("./session.js", () => ({
177+
resolveCronSession: resolveCronSessionMock,
178+
}));
179+
180+
vi.mock("../../agents/defaults.js", () => ({
181+
DEFAULT_CONTEXT_TOKENS: 128000,
182+
DEFAULT_MODEL: "gpt-4",
183+
DEFAULT_PROVIDER: "openai",
184+
}));
185+
186+
const { runCronIsolatedAgentTurn } = await import("./run.js");
187+
188+
// ---------- helpers ----------
189+
190+
function makeJob(overrides?: Record<string, unknown>) {
191+
return {
192+
id: "test-job",
193+
name: "Test Job",
194+
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
195+
sessionTarget: "isolated",
196+
payload: { kind: "agentTurn", message: "test" },
197+
...overrides,
198+
} as never;
199+
}
200+
201+
function makeParams(overrides?: Record<string, unknown>) {
202+
return {
203+
cfg: {},
204+
deps: {} as never,
205+
job: makeJob(overrides?.job ? (overrides.job as Record<string, unknown>) : undefined),
206+
message: "test",
207+
sessionKey: "cron:test",
208+
...overrides,
209+
};
210+
}
211+
212+
// ---------- tests ----------
213+
214+
describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
215+
let previousFastTestEnv: string | undefined;
216+
217+
beforeEach(() => {
218+
vi.clearAllMocks();
219+
previousFastTestEnv = process.env.OPENCLAW_TEST_FAST;
220+
delete process.env.OPENCLAW_TEST_FAST;
221+
resolveAgentModelFallbacksOverrideMock.mockReturnValue(undefined);
222+
resolveCronSessionMock.mockReturnValue({
223+
storePath: "/tmp/store.json",
224+
store: {},
225+
sessionEntry: {
226+
sessionId: "test-session-id",
227+
updatedAt: 0,
228+
systemSent: false,
229+
skillsSnapshot: undefined,
230+
},
231+
systemSent: false,
232+
isNewSession: true,
233+
});
234+
});
235+
236+
afterEach(() => {
237+
if (previousFastTestEnv == null) {
238+
delete process.env.OPENCLAW_TEST_FAST;
239+
return;
240+
}
241+
process.env.OPENCLAW_TEST_FAST = previousFastTestEnv;
242+
});
243+
244+
it("passes payload.fallbacks as fallbacksOverride when defined", async () => {
245+
const result = await runCronIsolatedAgentTurn(
246+
makeParams({
247+
job: makeJob({
248+
payload: {
249+
kind: "agentTurn",
250+
message: "test",
251+
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"],
252+
},
253+
}),
254+
}),
255+
);
256+
257+
expect(result.status).toBe("ok");
258+
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
259+
expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual([
260+
"anthropic/claude-sonnet-4-6",
261+
"openai/gpt-5",
262+
]);
263+
});
264+
265+
it("falls back to agent-level fallbacks when payload.fallbacks is undefined", async () => {
266+
resolveAgentModelFallbacksOverrideMock.mockReturnValue(["openai/gpt-4o"]);
267+
268+
const result = await runCronIsolatedAgentTurn(
269+
makeParams({
270+
job: makeJob({ payload: { kind: "agentTurn", message: "test" } }),
271+
}),
272+
);
273+
274+
expect(result.status).toBe("ok");
275+
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
276+
expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual(["openai/gpt-4o"]);
277+
});
278+
279+
it("payload.fallbacks=[] disables fallbacks even when agent config has them", async () => {
280+
resolveAgentModelFallbacksOverrideMock.mockReturnValue(["openai/gpt-4o"]);
281+
282+
const result = await runCronIsolatedAgentTurn(
283+
makeParams({
284+
job: makeJob({
285+
payload: { kind: "agentTurn", message: "test", fallbacks: [] },
286+
}),
287+
}),
288+
);
289+
290+
expect(result.status).toBe("ok");
291+
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
292+
expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual([]);
293+
});
294+
});

src/cron/isolated-agent/run.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,12 +446,18 @@ export async function runCronIsolatedAgentTurn(params: {
446446
verboseLevel: resolvedVerboseLevel,
447447
});
448448
const messageChannel = resolvedDelivery.channel;
449+
// Per-job payload.fallbacks takes priority over agent-level fallbacks.
450+
const payloadFallbacks =
451+
params.job.payload.kind === "agentTurn" && Array.isArray(params.job.payload.fallbacks)
452+
? params.job.payload.fallbacks
453+
: undefined;
449454
const fallbackResult = await runWithModelFallback({
450455
cfg: cfgWithAgentDefaults,
451456
provider,
452457
model,
453458
agentDir,
454-
fallbacksOverride: resolveAgentModelFallbacksOverride(params.cfg, agentId),
459+
fallbacksOverride:
460+
payloadFallbacks ?? resolveAgentModelFallbacksOverride(params.cfg, agentId),
455461
run: (providerOverride, modelOverride) => {
456462
if (abortSignal?.aborted) {
457463
throw new Error(abortReason());

src/cron/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export type CronPayload =
6363
message: string;
6464
/** Optional model override (provider/model or alias). */
6565
model?: string;
66+
/** Optional per-job fallback models; overrides agent/global fallbacks when defined. */
67+
fallbacks?: string[];
6668
thinking?: string;
6769
timeoutSeconds?: number;
6870
allowUnsafeExternalContent?: boolean;
@@ -78,6 +80,7 @@ export type CronPayloadPatch =
7880
kind: "agentTurn";
7981
message?: string;
8082
model?: string;
83+
fallbacks?: string[];
8184
thinking?: string;
8285
timeoutSeconds?: number;
8386
allowUnsafeExternalContent?: boolean;

src/gateway/protocol/schema/cron.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema }) {
77
kind: Type.Literal("agentTurn"),
88
message: params.message,
99
model: Type.Optional(Type.String()),
10+
fallbacks: Type.Optional(Type.Array(Type.String())),
1011
thinking: Type.Optional(Type.String()),
1112
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
1213
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),

0 commit comments

Comments
 (0)