|
4 | 4 | BEST_REVIEW_MODELS, |
5 | 5 | buildTestEvidencePromptSection, |
6 | 6 | callAiProvider, |
| 7 | + formatReviewDiagnosticsForCapture, |
7 | 8 | INCOHERENT_DIFF_ASSESSMENT, |
8 | 9 | isIncoherentDiffBail, |
9 | 10 | isStructuralProviderConfigError, |
@@ -3366,6 +3367,47 @@ describe("pure helpers", () => { |
3366 | 3367 | warnSpy.mockRestore(); |
3367 | 3368 | }); |
3368 | 3369 |
|
| 3370 | + it("REGRESSION (LOOPOVER-2A): the exhausted log carries each model's OWN terminal error, so the fallback's failure cannot mask the primary's", async () => { |
| 3371 | + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); |
| 3372 | + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); |
| 3373 | + // The 2026-07-23 outage shape: the primary rate-limits (429 → no same-model retry), the fallback fails |
| 3374 | + // structurally (circuit_open). `error` alone reported only the fallback's message, hiding the 429. |
| 3375 | + const run = vi.fn(async (model: string) => { |
| 3376 | + throw new Error(model === "primary-model" ? "claude_code_error_429" : "circuit_open: provider down"); |
| 3377 | + }); |
| 3378 | + const env = createTestEnv({ AI: { run } as unknown as Ai }); |
| 3379 | + const result = await runWorkersOpinion(env, "primary-model", "fallback-model", "sys", "user", 256); |
| 3380 | + expect(result).toEqual({ review: null }); |
| 3381 | + const exhausted = logSpy.mock.calls |
| 3382 | + .map((c) => c[0]) |
| 3383 | + .find((l) => typeof l === "string" && l.includes("ai_review_provider_exhausted")); |
| 3384 | + expect(exhausted).toBeDefined(); |
| 3385 | + expect(JSON.parse(exhausted as string)).toMatchObject({ |
| 3386 | + event: "ai_review_provider_exhausted", |
| 3387 | + // Still the last error overall (unchanged Sentry grouping)… |
| 3388 | + error: expect.stringContaining("circuit_open"), |
| 3389 | + // …but now ALSO each model's own terminal failure, keyed by model. |
| 3390 | + errorsByModel: { |
| 3391 | + "primary-model": "claude_code_error_429", |
| 3392 | + "fallback-model": "circuit_open: provider down", |
| 3393 | + }, |
| 3394 | + }); |
| 3395 | + logSpy.mockRestore(); |
| 3396 | + warnSpy.mockRestore(); |
| 3397 | + }); |
| 3398 | + |
| 3399 | + it("formatReviewDiagnosticsForCapture renders compact model#attempt:status[:error] strings (raw objects flatten to \"[Object]\" in Sentry context — LOOPOVER-2B)", () => { |
| 3400 | + const diagnostics: AiReviewDiagnostic[] = [ |
| 3401 | + { model: "claude-code", attempt: 0, status: "provider_error", error: "claude_code_error_429" }, |
| 3402 | + { model: "codex", attempt: 1, status: "unparseable_output", responseChars: 12, hasJsonObject: false }, |
| 3403 | + ]; |
| 3404 | + expect(formatReviewDiagnosticsForCapture(diagnostics)).toEqual([ |
| 3405 | + "claude-code#0:provider_error:claude_code_error_429", |
| 3406 | + "codex#1:unparseable_output", |
| 3407 | + ]); |
| 3408 | + expect(formatReviewDiagnosticsForCapture([])).toEqual([]); |
| 3409 | + }); |
| 3410 | + |
3369 | 3411 | it("logs unparseable exhaustion separately when the model runs but returns unparseable output, including a response snippet for diagnosis (#observability-unparseable)", async () => { |
3370 | 3412 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); |
3371 | 3413 | const run = vi.fn(async () => ({ response: "not json at all" })); |
|
0 commit comments