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