|
| 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 | +}); |
0 commit comments