feat(cron): add payload.fallbacks for per-job model fallback override (#26120) (#26304)

Co-authored-by: yinghaosang <yinghaosang@users.noreply.github.com>
This commit is contained in:
yinghaosang
2026-03-01 22:11:03 +08:00
committed by GitHub
parent 8c98cf05b2
commit f902697bd5
4 changed files with 305 additions and 1 deletions

View File

@@ -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: "<available_skills></available_skills>",
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<typeof import("../../agents/model-selection.js")>();
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<typeof import("../../routing/session-key.js")>();
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<string, unknown>) {
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<string, unknown>) {
return {
cfg: {},
deps: {} as never,
job: makeJob(overrides?.job ? (overrides.job as Record<string, unknown>) : 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([]);
});
});

View File

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

View File

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

View File

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