Merge branch 'release/v3.8.47' into fix/6803-flaky-timing-tests

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-11 23:05:16 -03:00
committed by GitHub
10 changed files with 572 additions and 21 deletions

View File

@@ -0,0 +1 @@
- **chore(ci):** fix two shared base-reds on the release tip that blocked the PR queue — register `cliproxyapi-model-mapping-dispatch.test.ts` in `stryker.conf.json` `tap.testFiles` (mutation-coverage gap left by #6903) and update `provider-models-route-codex.test.ts` to expect Codex client version `0.144.0` (stale assertion left by #6780's production bump).

View File

@@ -354,14 +354,31 @@ export async function handleFusionChat({
// surviving panel answer, rather than silently substituting the panel
// member for the configured judge (issue #6455). The judge still adds
// value reviewing/polishing a lone source per its documented contract.
}
// Resolve the judge that ACTUALLY runs synthesis. An explicit judgeModel is
// honored as configured (operator intent — kept even if it was down during
// fan-out; that's the operator's choice). With NO explicit judge the judge
// defaulted to panel[0] — but panel[0] may have FAILED fan-out (timeout /
// rate-limit / dropped straggler → it lands in `failures`, not `answers`).
// Handing synthesis to a dead panel[0] sinks the whole request despite a
// healthy quorum — exactly the case fusion exists to tolerate. So pick a
// SURVIVOR: prefer panel[0] when it survived, otherwise the first survivor.
const effectiveJudge = hasExplicitJudge
? judge
: answers.some((a) => a.model === panel[0])
? panel[0]
: answers[0].model;
if (answers.length === 1) {
log.info(
"FUSION",
`Only ${answers[0].model} succeeded — judging single answer with ${judge}`
`Only ${answers[0].model} succeeded — judging single answer with ${effectiveJudge}`
);
}
// 4. Judge analyzes + writes one final answer (streams to client if requested).
const judgeBody = appendUserTurn(body, buildJudgePrompt(answers));
log.info("FUSION", `Judging ${answers.length} answers with ${judge}`);
return handleSingleModel(judgeBody, judge);
log.info("FUSION", `Judging ${answers.length} answers with ${effectiveJudge}`);
return handleSingleModel(judgeBody, effectiveJudge);
}

View File

@@ -46,9 +46,13 @@ export default function LogsPage() {
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// initial id from URL (synchronously on client) so child can open on mount
const initialId =
typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null;
// initial id from URL (synchronously on client) so child can open on mount.
// Read once via lazy state: window.location lags router.replace() by one
// render, so re-reading it on every render flips this prop mid-session and
// re-triggers the child's deep-link effect right when the modal closes.
const [initialId] = useState(() =>
typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null
);
async function handleExport(hours: number) {
setExporting(true);

View File

@@ -4,11 +4,11 @@
* POST — Create a new mapping
*/
import { z } from "zod";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getModelComboMappings, createModelComboMapping } from "@/lib/localDb";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { createModelComboMapping, getModelComboMappings } from "@/lib/localDb";
import { validatedJsonBody } from "@/shared/validation/helpers";
const createMappingSchema = z.object({
pattern: z.string().min(1, "Pattern is required").max(500),
@@ -36,13 +36,12 @@ export async function POST(request: Request) {
if (authError) return authError;
try {
const rawBody = await request.json();
const validation = validateBody(createMappingSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
const parsed = await validatedJsonBody(request, createMappingSchema);
if (!parsed.success) {
return parsed.response;
}
const { data } = validation;
const { data } = parsed;
const mapping = await createModelComboMapping({
pattern: data.pattern.trim(),
comboId: data.comboId,

View File

@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import { getPluginByName, updatePluginConfig } from "@/lib/db/plugins";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getPluginByName, updatePluginConfig } from "@/lib/db/plugins";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
export async function OPTIONS() {
return handleCorsOptions();
@@ -41,7 +41,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { name } = await params;
const body = await request.json();
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(buildErrorBody(400, "Invalid JSON body"), {
status: 400,
headers: CORS_HEADERS,
});
}
const schema = z.object({
config: z.record(z.string(), z.unknown()),

View File

@@ -108,6 +108,7 @@
"tests/unit/cli-simulate.test.ts",
"tests/unit/cline-response-envelope.test.ts",
"tests/unit/clinepass-provider.test.ts",
"tests/unit/cliproxyapi-model-mapping-dispatch.test.ts",
"tests/unit/codex-failover.test.ts",
"tests/unit/codex-quota-selection-hydration.test.ts",
"tests/unit/codex-session-affinity-reset-aware-5903.test.ts",

View File

@@ -0,0 +1,173 @@
/**
* Regression tests: mutating API routes must return 400 (not 500) on a malformed
* JSON request body.
*
* Before the fix, both handlers called `await request.json()` on a raw body:
* - PUT /api/plugins/[name]/config had no try/catch → unhandled 500.
* - POST /api/model-combo-mappings parsed inside the outer try whose catch
* returns a generic 500.
* A malformed body must instead surface as a clean 400 with the standard error
* envelope, while a well-formed body keeps its existing behavior.
*
* DB/auth setup mirrors tests/unit/agentSkills-routes.test.ts: a temp DATA_DIR
* with no configured password means requireManagementAuth() is a no-op (auth is
* not required), so the handlers run unauthenticated. DB handles are released in
* test.after (resetDbInstance) per CLAUDE.md — unreleased SQLite handles hang the
* Node test runner.
*/
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
// ── DB / auth setup ─────────────────────────────────────────────────────────
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-malformed-json-400-"));
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET;
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "malformed-json-400-test-secret";
delete process.env.INITIAL_PASSWORD; // ensure auth is NOT required
// Import DB first (order matters — sets DATA_DIR before localDb loads)
const core = await import("../../src/lib/db/core.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
// Import routes AFTER env vars are set
const pluginConfigRoute = await import("../../src/app/api/plugins/[name]/config/route.ts");
const modelComboRoute = await import("../../src/app/api/model-combo-mappings/route.ts");
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Build a POST/PUT Request that CLAIMS to be JSON but carries a non-JSON body. */
function malformedJsonRequest(url: string, method = "POST"): Request {
return new Request(url, {
method,
headers: { "content-type": "application/json" },
body: "not-json",
});
}
/** Build a POST/PUT Request with a well-formed JSON body. */
function jsonRequest(url: string, body: unknown, method = "POST"): Request {
return new Request(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
if (ORIGINAL_API_KEY_SECRET === undefined) delete process.env.API_KEY_SECRET;
else process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET;
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
});
// ═════════════════════════════════════════════════════════════════════════════
// PUT /api/plugins/[name]/config
// ═════════════════════════════════════════════════════════════════════════════
test("PUT /api/plugins/[name]/config — malformed JSON body returns 400 (was 500)", async () => {
const req = malformedJsonRequest("http://localhost/api/plugins/demo/config", "PUT");
const res = await pluginConfigRoute.PUT(req, {
params: Promise.resolve({ name: "demo" }),
});
assert.equal(res.status, 400, "malformed JSON must yield 400, not an unhandled 500");
const body = (await res.json()) as { error?: { message?: string } };
assert.ok(body.error, "response must carry a structured error envelope");
assert.equal(typeof body.error.message, "string");
// Hard rule #12: no stack trace leaked in the error message.
assert.ok(!body.error.message?.includes("at /"), "error message must not leak a stack trace");
});
test("PUT /api/plugins/[name]/config — valid body does NOT 400 on parse (404 for unknown plugin)", async () => {
// Well-formed body against a plugin that does not exist: the handler must get
// PAST body parsing/validation and reach the not-found branch (404) — proving
// the happy path is unregressed.
const req = jsonRequest(
"http://localhost/api/plugins/does-not-exist/config",
{ config: { foo: "bar" } },
"PUT"
);
const res = await pluginConfigRoute.PUT(req, {
params: Promise.resolve({ name: "does-not-exist" }),
});
assert.notEqual(res.status, 400, "a well-formed body must not be rejected as invalid JSON");
assert.equal(res.status, 404, "unknown plugin should surface as 404, past the parse guard");
});
// ═════════════════════════════════════════════════════════════════════════════
// POST /api/model-combo-mappings
// ═════════════════════════════════════════════════════════════════════════════
test("POST /api/model-combo-mappings — malformed JSON body returns 400 (was 500)", async () => {
const req = malformedJsonRequest("http://localhost/api/model-combo-mappings");
const res = await modelComboRoute.POST(req);
assert.equal(res.status, 400, "malformed JSON must yield 400, not a generic 500");
const body = (await res.json()) as {
error?: {
message?: string;
details?: Array<{ field: string; message: string }>;
};
};
// Uses the canonical validatedJsonBody envelope.
assert.equal(body.error?.message, "Invalid request");
assert.deepEqual(body.error?.details, [{ field: "body", message: "Invalid JSON body" }]);
});
test("POST /api/model-combo-mappings — well-formed body succeeds (201), no parse regression", async () => {
// Seed a real combo so the mapping's FK (combo_id → combos.id) is satisfied.
await combosDb.createCombo({
id: "combo-under-test",
name: "combo-under-test",
});
const req = jsonRequest("http://localhost/api/model-combo-mappings", {
pattern: "gpt-4*",
comboId: "combo-under-test",
});
const res = await modelComboRoute.POST(req);
assert.equal(res.status, 201, "a valid body must still create the mapping (201)");
const body = (await res.json()) as {
mapping?: { pattern?: string; comboId?: string };
};
assert.equal(body.mapping?.pattern, "gpt-4*");
assert.equal(body.mapping?.comboId, "combo-under-test");
});
test("POST /api/model-combo-mappings — well-formed but invalid body still returns 400", async () => {
// Empty pattern fails the Zod schema — same 400 envelope as the malformed path.
const req = jsonRequest("http://localhost/api/model-combo-mappings", {
pattern: "",
comboId: "combo-x",
});
const res = await modelComboRoute.POST(req);
assert.equal(res.status, 400);
const body = (await res.json()) as {
error?: { message?: string; details?: Array<{ field: string }> };
};
assert.equal(body.error?.message, "Invalid request");
assert.ok(
body.error?.details?.some((d) => d.field === "pattern"),
"validation failure should name the offending field"
);
});

View File

@@ -0,0 +1,141 @@
/**
* Regression test: with NO explicit judgeModel, the synthesis judge must be a
* SURVIVING panel member — never a panel[0] that failed fan-out.
*
* Bug: `handleFusionChat` fixed the default judge to `panel[0]` BEFORE fan-out
* and never reassigned it. When panel[0] timed out / was rate-limited / dropped
* as a straggler it landed in `failures`, not `answers` — yet the multi-answer
* synthesis path still dispatched `handleSingleModel(judgeBody, panel[0])`,
* handing synthesis to a dead model. The whole fusion request then errored even
* though a quorum of OTHER panel members succeeded — exactly the failure mode
* fusion exists to tolerate.
*
* Fix: when no explicit judge is configured, resolve the effective judge from a
* survivor (prefer panel[0] only when it survived, else the first survivor). An
* explicitly configured judge is still honored unchanged.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { handleFusionChat } from "../../open-sse/services/fusion.ts";
const noop = () => {};
const log = { info: noop, warn: noop, debug: noop, error: noop };
type Body = Record<string, unknown>;
function okResponse(content: string): Promise<Response> {
const body = JSON.stringify({
choices: [{ message: { role: "assistant", content } }],
});
return Promise.resolve(
new Response(body, {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
}
function errResponse(status: number): Promise<Response> {
const body = JSON.stringify({ error: { message: "boom" } });
return Promise.resolve(
new Response(body, {
status,
headers: { "Content-Type": "application/json" },
})
);
}
const PANEL = ["prov/model-a", "prov/model-b", "prov/model-c"];
test("fusion judge-survivor: no explicit judge + panel[0] fails fan-out → synthesis uses a surviving member, not the dead panel[0]", async () => {
const seen: string[] = [];
const handleSingleModel = (_b: Body, m: string) => {
seen.push(m);
// panel[0] (model-a) fails fan-out; B & C succeed.
if (m === "prov/model-a") return errResponse(429);
return okResponse(`ans-${m}`);
};
const res = await handleFusionChat({
body: { messages: [{ role: "user", content: "hi" }] },
models: PANEL,
handleSingleModel,
log,
// NO explicit judge — this is the default-judge path that was broken.
tuning: { minPanel: 1, stragglerGraceMs: 4000, panelHardTimeoutMs: 60000 },
});
assert.notEqual(
res.status,
503,
"a healthy quorum (B, C) must not error just because panel[0] died"
);
const body = (await res.clone().json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
assert.ok(
(body.choices?.[0]?.message?.content ?? "").length > 0,
"must carry a real synthesized answer"
);
// The synthesis dispatch is the LAST handleSingleModel call.
const synthesisJudge = seen[seen.length - 1];
assert.notEqual(synthesisJudge, "prov/model-a", "judge must NOT be the failed panel[0]");
assert.ok(
synthesisJudge === "prov/model-b" || synthesisJudge === "prov/model-c",
`judge must be a survivor (B or C), got ${synthesisJudge}`
);
});
test("fusion judge-survivor: no explicit judge + panel[0] survives → panel[0] is still chosen (existing-good case unchanged)", async () => {
const seen: string[] = [];
const handleSingleModel = (_b: Body, m: string) => {
seen.push(m);
// panel[0] (model-a) survives; model-b fails.
if (m === "prov/model-b") return errResponse(429);
return okResponse(`ans-${m}`);
};
const res = await handleFusionChat({
body: { messages: [{ role: "user", content: "hi" }] },
models: PANEL,
handleSingleModel,
log,
tuning: { minPanel: 1, stragglerGraceMs: 4000, panelHardTimeoutMs: 60000 },
});
assert.notEqual(res.status, 503);
assert.equal(
seen[seen.length - 1],
"prov/model-a",
"when panel[0] survives it remains the default judge"
);
});
test("fusion judge-survivor: explicit judge is honored unchanged even if it failed fan-out", async () => {
const seen: string[] = [];
const handleSingleModel = (_b: Body, m: string) => {
seen.push(m);
// The configured judge (model-a) fails fan-out; B & C succeed.
if (m === "prov/model-a") return errResponse(429);
return okResponse(`ans-${m}`);
};
const res = await handleFusionChat({
body: { messages: [{ role: "user", content: "hi" }] },
models: PANEL,
handleSingleModel,
log,
judgeModel: "prov/model-a", // explicit — operator intent is honored as-is.
tuning: { minPanel: 1, stragglerGraceMs: 4000, panelHardTimeoutMs: 60000 },
});
assert.notEqual(res.status, 503);
assert.equal(
seen[seen.length - 1],
"prov/model-a",
"an explicitly configured judge is dispatched unchanged (operator's choice)"
);
});

View File

@@ -145,11 +145,11 @@ test("provider models route discovers live Codex models and preserves static ali
assert.equal(body.source, "api");
assert.deepEqual(seenRequests, [
{
url: "https://chatgpt.com/backend-api/codex/models?client_version=0.142.0",
url: "https://chatgpt.com/backend-api/codex/models?client_version=0.144.0",
authorization: "Bearer codex-access-token",
workspaceId: "account-123",
originator: "codex_cli_rs",
userAgent: "codex-cli/0.142.0 (Windows 10.0.26200; x64)",
userAgent: "codex-cli/0.144.0 (Windows 10.0.26200; x64)",
},
{
url: "https://raw.githubusercontent.com/openai/codex/refs/heads/main/codex-rs/models-manager/models.json",

View File

@@ -0,0 +1,206 @@
// @vitest-environment jsdom
/**
* TDD regression: dashboard/logs — closing the request-detail modal after the
* FIRST row click immediately reopens it; only the second close works.
*
* Root cause: LogsPage computes `initialId` from `window.location` on EVERY
* render, but Next.js App Router syncs `window.location` only after the
* navigation commits — i.e. after the re-render triggered by
* `router.replace()`. So:
*
* 1. Row click → openDetail → router.replace("?id=X"): page re-renders while
* location is still the old URL → initialId stays null, the child's
* one-shot `initialOpenedRef` guard is never consumed.
* 2. First close → closeDetail → router.replace(no id): page re-renders while
* location STILL carries "?id=X" → initialId flips null → "X" → the child's
* deep-link effect fires (guard still unarmed) → openDetail reopens the
* modal.
* 3. Second close works because the guard is now armed.
*
* The router mock below reproduces that ordering: replace() re-renders the
* page synchronously (like the App Router segment re-render) and the URL is
* committed to window.location separately, after the render — via
* commitPendingUrl().
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const routerControl = vi.hoisted(() => ({
pendingUrl: null as string | null,
bumpPageRender: () => {},
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
replace: (url: string) => {
// Mirror App Router ordering: the segment re-renders first, the URL is
// synced to window.location only after the commit (commitPendingUrl()).
routerControl.pendingUrl = url;
routerControl.bumpPageRender();
},
push: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/dashboard/logs",
useSearchParams: () => new URLSearchParams(globalThis.location.search),
}));
vi.mock("@/store/emailPrivacyStore", () => ({
default: () => ({ emailsVisible: true }),
}));
// LogsPage imports { ConfirmModal, RequestLoggerV2 } from the barrel; keep the
// real logger (the component under test) and stub the unrelated ConfirmModal
// so the test doesn't drag the whole barrel into jsdom.
vi.mock("@/shared/components", async () => {
const { default: RequestLoggerV2 } = await import(
"../../../src/shared/components/RequestLoggerV2.tsx"
);
const ConfirmModal = ({ isOpen }: { isOpen: boolean }) =>
isOpen ? <div data-testid="confirm-modal" /> : null;
return { RequestLoggerV2, ConfirmModal };
});
const { default: LogsPage } = await import(
"../../../src/app/(dashboard)/dashboard/logs/page.tsx"
);
// Stands in for the App Router segment root: router.replace() re-renders the
// whole page tree, which is exactly what re-evaluates LogsPage's initialId.
function Harness() {
const [, setVersion] = React.useState(0);
React.useEffect(() => {
routerControl.bumpPageRender = () => setVersion((v) => v + 1);
return () => {
routerControl.bumpPageRender = () => {};
};
}, []);
return <LogsPage />;
}
function commitPendingUrl() {
if (routerControl.pendingUrl != null) {
window.history.replaceState(null, "", routerControl.pendingUrl);
routerControl.pendingUrl = null;
}
}
class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
const LOG_ROW = {
id: "log-1",
status: 200,
timestamp: new Date().toISOString(),
model: "gpt-4o",
provider: "openai",
account: "user@example.com",
tokens: { in: 10, out: 20 },
duration: 1234,
};
let container: HTMLElement;
let root: Root;
async function settle() {
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
}
beforeEach(() => {
localStorage.clear();
routerControl.pendingUrl = null;
routerControl.bumpPageRender = () => {};
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith("/api/usage/call-logs")) {
return Response.json([LOG_ROW]);
}
if (url.startsWith("/api/logs/detail")) {
return Response.json({ enabled: false });
}
if (url.startsWith(`/api/logs/${LOG_ROW.id}`)) {
return Response.json({ ...LOG_ROW, active: false });
}
if (url.startsWith("/api/provider-nodes")) {
return Response.json({ nodes: [] });
}
return Response.json({});
})
);
vi.useFakeTimers();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
window.history.replaceState(null, "", "/dashboard/logs");
});
describe("LogsPage detail modal — first-close reopen regression", () => {
it("closing the modal after the first row click keeps it closed", async () => {
window.history.replaceState(null, "", "/dashboard/logs");
await act(async () => {
root.render(<Harness />);
});
await settle();
// First click on the log row opens the detail modal.
const row = container.querySelector("tbody tr") as HTMLTableRowElement;
expect(row).not.toBeNull();
await act(async () => {
row.click();
});
await settle();
// Navigation commits after the render: URL now carries ?id=log-1.
commitPendingUrl();
expect(window.location.search).toContain("id=log-1");
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
// First close: closeDetail() re-renders the page while window.location
// still has ?id=log-1 (the close navigation has not committed yet).
const dialog = container.querySelector('[role="dialog"]') as HTMLElement;
await act(async () => {
dialog.click(); // backdrop click → onClose
});
await settle();
// The modal must stay closed — the stale ?id in location must not reopen it.
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it("deep link ?id= still opens the modal on mount", async () => {
window.history.replaceState(null, "", `/dashboard/logs?id=${LOG_ROW.id}`);
await act(async () => {
root.render(<Harness />);
});
await settle();
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
});
});