fix(dashboard): align Engine Combos editor engines with API schema (#4955) (#5062)

The named-combos pipeline dropdown offered four engines (headroom,
session-dedup, ccr, llmlingua) that stackedPipelineStepSchema rejects, so
selecting one made PUT /api/context/combos/[id] return HTTP 400 while
saveCombo swallowed the non-OK response (if (!res.ok) return). Editing the
default 'Standard Savings' combo and changing an engine reproduced the 400.

- Add canonical STACKED_PIPELINE_ENGINE_INTENSITIES next to the schema as the
  single source of truth; the client dropdown imports it so it can never drift
  from the discriminated union the API validates against.
- Surface save errors and empty-name/empty-pipeline validation in the editor
  instead of failing silently.
- Add a parity unit test asserting the UI engine map equals the schema union
  and that every (engine, intensity) the UI emits is accepted.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-25 23:07:51 -03:00
committed by GitHub
parent d5ffdf2a5e
commit ba6f2e034c
4 changed files with 107 additions and 13 deletions

View File

@@ -8,6 +8,16 @@
_In development — bullets added per PR; finalized at release._
### 🔧 Bug Fixes
- **Engine Combos editor: saving a pipeline no longer fails silently with HTTP 400 (#4955).** The
named-combos pipeline dropdown offered four engines (`headroom`, `session-dedup`, `ccr`,
`llmlingua`) that the `PUT /api/context/combos/[id]` schema rejects, so selecting one made the
save return 400 while the UI swallowed the error. The dropdown is now sourced from a single
canonical engine map shared with `stackedPipelineStepSchema` (parity guarded by a unit test), and
the editor surfaces save errors and empty-name/empty-pipeline validation instead of failing
quietly.
---
## [3.8.36] — 2026-06-25

View File

@@ -8,6 +8,7 @@
// matching `EngineConfigPage` / `CompressionHub`, both of which hydrate cleanly.
import { useEffect, useState } from "react";
import { STACKED_PIPELINE_ENGINE_INTENSITIES } from "@/shared/validation/compressionConfigSchemas";
import CompressionHub from "./CompressionHub";
type PipelineStep = { engine: string; intensity?: string };
@@ -29,17 +30,9 @@ const EMPTY_PIPELINE: PipelineStep[] = [
{ engine: "caveman", intensity: "full" },
];
const ENGINE_INTENSITIES: Record<string, string[]> = {
rtk: ["minimal", "standard", "aggressive"],
caveman: ["lite", "full", "ultra"],
lite: ["lite"],
aggressive: ["standard"],
ultra: ["ultra"],
headroom: ["standard"],
"session-dedup": ["standard"],
ccr: ["standard"],
llmlingua: ["standard"],
};
// Engine list is sourced from the API schema so the dropdown can never offer an engine
// the `PUT /api/context/combos/[id]` route would reject with HTTP 400 (#4955).
const ENGINE_INTENSITIES: Record<string, readonly string[]> = STACKED_PIPELINE_ENGINE_INTENSITIES;
function NamedCombosManager() {
const [combos, setCombos] = useState<CompressionCombo[]>([]);
@@ -55,6 +48,7 @@ function NamedCombosManager() {
const [assignmentIds, setAssignmentIds] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [activeComboId, setActiveComboId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = () => {
fetch("/api/context/combos")
@@ -88,6 +82,7 @@ function NamedCombosManager() {
setOutputMode(false);
setOutputModeIntensity("full");
setAssignmentIds([]);
setError(null);
};
const loadAssignments = async (id: string) => {
@@ -112,7 +107,15 @@ function NamedCombosManager() {
const saveCombo = async () => {
const trimmed = name.trim();
if (!trimmed) return;
if (!trimmed) {
setError("Enter a combo name before saving.");
return;
}
if (pipeline.length === 0) {
setError("Add at least one pipeline step before saving.");
return;
}
setError(null);
setSaving(true);
try {
const payload = {
@@ -131,7 +134,11 @@ function NamedCombosManager() {
body: JSON.stringify(payload),
}
);
if (!res.ok) return;
if (!res.ok) {
const body = await res.json().catch(() => null);
setError(body?.error || `Failed to save combo (HTTP ${res.status}).`);
return;
}
const combo = await res.json();
await fetch(`/api/context/combos/${combo.id}/assignments`, {
method: "PUT",
@@ -315,6 +322,12 @@ function NamedCombosManager() {
</div>
</div>
{error && (
<p className="mt-4 text-sm text-danger" role="alert">
{error}
</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
<button
onClick={saveCombo}

View File

@@ -177,6 +177,26 @@ export const stackedPipelineStepSchema = z.discriminatedUnion("engine", [
.strict(),
]);
/**
* Canonical engine → selectable-intensities map for the named-combos pipeline editor
* (Engine Combos UI). This is the SINGLE source of truth shared by the dashboard
* dropdowns and `stackedPipelineStepSchema`: every engine/intensity offered here is,
* by construction, accepted by the API update schema.
*
* Do NOT add an engine here that is not a branch of `stackedPipelineStepSchema` — the
* `PUT /api/context/combos/[id]` route validates against that discriminated union and
* would reject the payload with HTTP 400 (#4955: the UI previously offered `headroom`,
* `session-dedup`, `ccr`, `llmlingua`, none of which the union accepts, so selecting
* one silently failed the save). The parity is guarded by a unit test.
*/
export const STACKED_PIPELINE_ENGINE_INTENSITIES: Record<string, readonly string[]> = {
rtk: ["minimal", "standard", "aggressive"],
caveman: ["lite", "full", "ultra"],
lite: ["lite"],
aggressive: ["standard"],
ultra: ["ultra"],
};
export const engineToggleSchema = z.object({
enabled: z.boolean(),
level: z.string().optional(),

View File

@@ -0,0 +1,51 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
STACKED_PIPELINE_ENGINE_INTENSITIES,
stackedPipelineStepSchema,
} from "../../../src/shared/validation/compressionConfigSchemas.ts";
// Regression guard for #4955: the Engine Combos pipeline editor used to offer engines
// (headroom, session-dedup, ccr, llmlingua) that `stackedPipelineStepSchema` rejects, so
// selecting one made `PUT /api/context/combos/[id]` fail with HTTP 400 and the UI swallowed
// it. The fix routes the dropdown through STACKED_PIPELINE_ENGINE_INTENSITIES, which MUST stay
// in lockstep with the discriminated union below.
describe("Engine Combos UI ↔ stackedPipelineStepSchema parity (#4955)", () => {
const unionEngines = stackedPipelineStepSchema.options
.map((option: { shape: { engine: { value: string } } }) => option.shape.engine.value)
.sort();
it("offers exactly the engines the API update schema accepts (no drift)", () => {
const uiEngines = Object.keys(STACKED_PIPELINE_ENGINE_INTENSITIES).sort();
assert.deepEqual(uiEngines, unionEngines);
});
it("every (engine, intensity) the UI can emit is accepted by the schema", () => {
for (const [engine, intensities] of Object.entries(STACKED_PIPELINE_ENGINE_INTENSITIES)) {
for (const intensity of intensities) {
const result = stackedPipelineStepSchema.safeParse({ engine, intensity });
assert.equal(
result.success,
true,
`expected { engine: "${engine}", intensity: "${intensity}" } to be accepted`
);
}
}
});
it("the engines removed from the UI in #4955 are indeed rejected by the schema", () => {
for (const engine of ["headroom", "session-dedup", "ccr", "llmlingua"]) {
assert.equal(
stackedPipelineStepSchema.safeParse({ engine, intensity: "standard" }).success,
false,
`engine "${engine}" must not be a valid stacked-pipeline step`
);
assert.equal(
STACKED_PIPELINE_ENGINE_INTENSITIES[engine],
undefined,
`engine "${engine}" must not be offered by the combos UI`
);
}
});
});