fix: cron model fallback to agent defaults when payload.model fails (#26717)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 06454bd55b
Co-authored-by: Youyou972 <50808411+Youyou972@users.noreply.github.com>
Co-authored-by: shakkernerd <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
This commit is contained in:
Youyou972
2026-02-25 18:34:31 -05:00
committed by GitHub
parent 2011edc9e5
commit 15cfba7075
3 changed files with 105 additions and 12 deletions

View File

@@ -6,6 +6,13 @@ import { runWithModelFallback } from "../../agents/model-fallback.js";
const buildWorkspaceSkillSnapshotMock = vi.fn();
const resolveAgentConfigMock = vi.fn();
const resolveAgentSkillsFilterMock = vi.fn();
const getModelRefStatusMock = vi.fn().mockReturnValue({ allowed: false });
const isCliProviderMock = vi.fn().mockReturnValue(false);
const resolveAllowedModelRefMock = vi.fn();
const resolveConfiguredModelRefMock = vi.fn();
const resolveHooksGmailModelMock = vi.fn();
const resolveThinkingDefaultMock = vi.fn();
const logWarnMock = vi.fn();
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: resolveAgentConfigMock,
@@ -36,14 +43,12 @@ 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),
getModelRefStatus: getModelRefStatusMock,
isCliProvider: isCliProviderMock,
resolveAllowedModelRef: resolveAllowedModelRefMock,
resolveConfiguredModelRef: resolveConfiguredModelRefMock,
resolveHooksGmailModel: resolveHooksGmailModelMock,
resolveThinkingDefault: resolveThinkingDefaultMock,
};
});
@@ -138,7 +143,7 @@ vi.mock("../../infra/skills-remote.js", () => ({
}));
vi.mock("../../logger.js", () => ({
logWarn: vi.fn(),
logWarn: (...args: unknown[]) => logWarnMock(...args),
}));
vi.mock("../../security/external-content.js", () => ({
@@ -222,6 +227,13 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
});
resolveAgentConfigMock.mockReturnValue(undefined);
resolveAgentSkillsFilterMock.mockReturnValue(undefined);
resolveConfiguredModelRefMock.mockReturnValue({ provider: "openai", model: "gpt-4" });
resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } });
resolveHooksGmailModelMock.mockReturnValue(null);
resolveThinkingDefaultMock.mockReturnValue(undefined);
getModelRefStatusMock.mockReturnValue({ allowed: false });
isCliProviderMock.mockReturnValue(false);
logWarnMock.mockReset();
// Fresh session object per test — prevents mutation leaking between tests
resolveCronSessionMock.mockReturnValue({
storePath: "/tmp/store.json",
@@ -408,5 +420,78 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
it("preserves defaults when agent overrides primary in object form", async () => {
await expectPrimaryOverridePreservesDefaults({ primary: "anthropic/claude-sonnet-4-5" });
});
it("applies payload.model override when model is allowed", async () => {
resolveAllowedModelRefMock.mockReturnValueOnce({
ref: { provider: "anthropic", model: "claude-sonnet-4-6" },
});
const result = await runCronIsolatedAgentTurn(
makeParams({
job: makeJob({
payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" },
}),
}),
);
expect(result.status).toBe("ok");
expect(logWarnMock).not.toHaveBeenCalled();
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
const runParams = runWithModelFallbackMock.mock.calls[0][0];
expect(runParams.provider).toBe("anthropic");
expect(runParams.model).toBe("claude-sonnet-4-6");
});
it("falls back to agent defaults when payload.model is not allowed", async () => {
resolveAllowedModelRefMock.mockReturnValueOnce({
error: "model not allowed: anthropic/claude-sonnet-4-6",
});
const result = await runCronIsolatedAgentTurn(
makeParams({
cfg: {
agents: {
defaults: {
model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks },
},
},
},
job: makeJob({
payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" },
}),
}),
);
expect(result.status).toBe("ok");
expect(logWarnMock).toHaveBeenCalledWith(
"cron: payload.model 'anthropic/claude-sonnet-4-6' not allowed, falling back to agent defaults",
);
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
const callCfg = runWithModelFallbackMock.mock.calls[0][0].cfg;
const model = callCfg?.agents?.defaults?.model as
| { primary?: string; fallbacks?: string[] }
| undefined;
expect(model?.primary).toBe("openai-codex/gpt-5.3-codex");
expect(model?.fallbacks).toEqual(defaultFallbacks);
});
it("returns an error when payload.model is invalid", async () => {
resolveAllowedModelRefMock.mockReturnValueOnce({
error: "invalid model: openai/",
});
const result = await runCronIsolatedAgentTurn(
makeParams({
job: makeJob({
payload: { kind: "agentTurn", message: "test", model: "openai/" },
}),
}),
);
expect(result.status).toBe("error");
expect(result.error).toBe("invalid model: openai/");
expect(logWarnMock).not.toHaveBeenCalled();
expect(runWithModelFallbackMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -198,10 +198,17 @@ export async function runCronIsolatedAgentTurn(params: {
defaultModel: resolvedDefault.model,
});
if ("error" in resolvedOverride) {
return { status: "error", error: resolvedOverride.error };
if (resolvedOverride.error.startsWith("model not allowed:")) {
logWarn(
`cron: payload.model '${modelOverride}' not allowed, falling back to agent defaults`,
);
} else {
return { status: "error", error: resolvedOverride.error };
}
} else {
provider = resolvedOverride.ref.provider;
model = resolvedOverride.ref.model;
}
provider = resolvedOverride.ref.provider;
model = resolvedOverride.ref.model;
}
const now = Date.now();
const cronSession = resolveCronSession({