From a6ea6074cb2bd47846552e44d0784a784e63726d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:50:11 -0300 Subject: [PATCH] fix(cli): compression REST fallback uses canonical defaultMode + JSON object cells (#6571) (#6682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): compression REST fallback uses canonical defaultMode + JSON object cells (#6571) * test(cli): align existing compression-command tests to the #6571 canonical field contract (engine→strategy/defaultMode) --- CHANGELOG.md | 1 + bin/cli/commands/compression.mjs | 18 ++- bin/cli/output.mjs | 1 + tests/unit/cli-compression-commands.test.ts | 9 +- ...compression-cli-rest-fallback-6571.test.ts | 152 ++++++++++++++++++ 5 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 tests/unit/compression-cli-rest-fallback-6571.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 95bb4e0abd..ec18fa8293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **fix(api):** `POST /api/keys` no longer hangs 20–90+ seconds on a fresh install, even with valid auth ([#6570](https://github.com/diegosouzapw/OmniRoute/issues/6570)) — `cloudEnabled` defaults to `true` in `src/lib/db/settings.ts::getSettings()` on any install with no persisted settings row (i.e. every fresh install), so the create-key handler's unconditional `await syncKeysToCloudIfEnabled()` always attempted a real outbound `fetch()` to `CLOUD_URL` via `syncToCloud()`; when that endpoint is unset/unreachable/slow, the HTTP response blocked until the request settled or timed out — unlike sibling routes (`POST /api/keys/:id/regenerate`, `GET /api/combos`), which never touch this side effect at all. `src/app/api/keys/route.ts` now dispatches `syncKeysToCloudIfEnabled()` fire-and-forget (`void`) instead of awaiting it; its internal try/catch already logs failures, so cloud sync still runs, it just no longer blocks the response. Regression guard: `tests/unit/api-keys-create-no-hang-6570.test.ts` (asserts the route resolves in well under 2s even when the Cloud-sync `fetch()` is stubbed to never settle) + updated `tests/integration/api-keys.test.ts` (the pre-existing "triggers cloud sync"/"still succeeds when cloud sync fails" tests, which previously hung indefinitely on this exact path, now await the fire-and-forget sync tick before asserting). - **fix(api):** editing any existing OpenAI Codex provider connection in the dashboard returned "Invalid request" and the edit could never be saved ([#6562](https://github.com/diegosouzapw/OmniRoute/issues/6562)) — `createProviderConnection()` (`src/lib/db/providers.ts`) auto-increments a new connection's `priority` to `MAX(priority)+1` per provider with no upper bound, and OAuth-imported connections (Codex `codex-auth/import` / `import-bulk`, up to 50 accounts per call, callable repeatedly — the standard Codex bulk-account-rotation workflow) never pass through `createProviderSchema`'s Zod validation at all, so nothing ever capped that value; `EditConnectionModal`'s `handleSubmit` always resends the connection's current `priority` unchanged on every save, and `updateProviderConnectionSchema` capped `priority`/`globalPriority` at `max(100)` — a UI-only ceiling the create path never enforced — so the first edit of any connection whose priority had already grown past 100 (routine once a Codex account count exceeds 100) failed validation regardless of which field the user actually changed. Raised the ceiling to `max(100_000)` on both fields — still bounded (a genuinely out-of-range value is still rejected), just wide enough to accept priorities the app itself already produces. Regression guard: `tests/unit/codex-connection-edit-6562.test.ts` (a Codex OAuth connection whose priority already exceeds the old 100 cap now validates + persists on edit; a control payload with a still-genuinely-invalid priority is still rejected with "Invalid request"). - **mimocode**: rotate accounts on MiMoCode's rate-limit-style 400s (body-classified) instead of failing on the first account; malformed 400s still fail fast with the real upstream error (#6648 — thanks @pizzav-xyz) +- **fix(cli):** compression CLI REST fallback now reads/writes the canonical `defaultMode` field (surfaced as `strategy`) instead of a nonexistent `engine` key, and table output renders nested objects as JSON instead of `[object Object]` (#6571 — thanks @charleszolot) ### 📝 Maintenance diff --git a/bin/cli/commands/compression.mjs b/bin/cli/commands/compression.mjs index 95bb6f6c18..6992497f69 100644 --- a/bin/cli/commands/compression.mjs +++ b/bin/cli/commands/compression.mjs @@ -24,7 +24,7 @@ async function restCompressionStatus() { const combosBody = combosRes.ok ? await combosRes.json() : { combos: [] }; const analytics = analyticsRes && analyticsRes.ok ? await analyticsRes.json() : null; return { - engine: settings.engine ?? null, + strategy: settings.defaultMode || "standard", settings, combos: combosBody.combos ?? combosBody, analytics, @@ -33,7 +33,10 @@ async function restCompressionStatus() { async function restCompressionConfigure(config) { const body = { ...config }; - if (body.engine) body.engine = normalizeEngine(body.engine); + if (body.strategy) { + body.defaultMode = body.strategy === "caveman" ? "standard" : normalizeEngine(body.strategy); + delete body.strategy; + } const res = await apiFetch("/api/settings/compression", { method: "PUT", body }); if (!res.ok) { process.stderr.write(`Error: ${res.status}\n`); @@ -43,9 +46,10 @@ async function restCompressionConfigure(config) { } async function restSetEngine(name) { + const normalized = normalizeEngine(name); const res = await apiFetch("/api/settings/compression", { method: "PUT", - body: { engine: normalizeEngine(name) }, + body: { defaultMode: normalized === "caveman" ? "standard" : normalized }, }); if (!res.ok) { process.stderr.write(`Error: ${res.status}\n`); @@ -103,7 +107,11 @@ export async function runCompressionStatus(opts, cmd) { export async function runCompressionConfigure(opts, cmd) { const config = {}; - if (opts.engine) config.engine = opts.engine; + // #6571 — both the MCP tool schema (compressionConfigureInput) and + // handleCompressionConfigure expect `strategy`, not `engine`; a non-strict + // MCP schema silently strips an unrecognized `engine` key on the primary + // (MCP-mounted) path, so this must be `strategy` on both paths. + if (opts.engine) config.strategy = normalizeEngine(opts.engine); if (opts.cavemanAggressiveness !== undefined) config.caveman = { aggressiveness: opts.cavemanAggressiveness }; if (opts.rtkBudget !== undefined) config.rtk = { tokenBudget: opts.rtkBudget }; @@ -163,7 +171,7 @@ export function registerCompression(program) { engine.command("set ").action(runCompressionEngineSet); engine.command("get").action(async (opts, cmd) => { const data = await mcpCall("omniroute_compression_status", {}, restCompressionStatus); - process.stdout.write(`${data.engine ?? "(default)"}\n`); + process.stdout.write(`${data.strategy ?? "(default)"}\n`); }); const combos = cmp.command("combos").description(t("compression.combos.description")); diff --git a/bin/cli/output.mjs b/bin/cli/output.mjs index 786c3da340..69f2518b74 100644 --- a/bin/cli/output.mjs +++ b/bin/cli/output.mjs @@ -37,6 +37,7 @@ function inferSchema(sample) { function formatCell(v, col) { if (v == null) return ""; if (col.formatter) return col.formatter(v); + if (typeof v === "object") return JSON.stringify(v); return String(v); } diff --git a/tests/unit/cli-compression-commands.test.ts b/tests/unit/cli-compression-commands.test.ts index 3f3cafed9c..3f4072a3e0 100644 --- a/tests/unit/cli-compression-commands.test.ts +++ b/tests/unit/cli-compression-commands.test.ts @@ -64,7 +64,10 @@ test("compression configure envia configuração via mcp", async () => { globalThis.fetch = origFetch; assert.equal(capturedBody.name, "omniroute_compression_configure"); - assert.equal(capturedBody.arguments.engine, "caveman"); + // #6571: the configure command now sends the canonical `strategy` field the MCP + // tool schema (compressionConfigureInput) + handleCompressionConfigure expect, + // not the nonexistent `engine` key (which the non-strict schema silently stripped). + assert.equal(capturedBody.arguments.strategy, "caveman"); assert.ok(capturedBody.arguments.caveman?.aggressiveness === 0.8); }); @@ -246,5 +249,7 @@ test("compression engine set falls back to PUT /api/settings/compression on MCP const restCall = calls.find((c) => c.url.includes("/api/settings/compression")); assert.ok(restCall, "should fall back to PUT /api/settings/compression"); assert.equal(restCall?.method, "PUT"); - assert.equal(restCall?.body?.engine, "rtk"); + // #6571: the REST fallback now PUTs the canonical `defaultMode` field the server's + // strict schema accepts, not the nonexistent `engine` key (which made the PUT 400). + assert.equal(restCall?.body?.defaultMode, "rtk"); }); diff --git a/tests/unit/compression-cli-rest-fallback-6571.test.ts b/tests/unit/compression-cli-rest-fallback-6571.test.ts new file mode 100644 index 0000000000..2816cce87a --- /dev/null +++ b/tests/unit/compression-cli-rest-fallback-6571.test.ts @@ -0,0 +1,152 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Repro for #6571 — REST-fallback path of `omniroute compression` (hit only when +// /api/mcp/tools/call is not mounted, i.e. mcpCall()'s 404/501 branch) uses the +// nonexistent `engine` field instead of the canonical `defaultMode` field, and +// the table renderer prints "[object Object]" for nested object cells. + +type MockResponse = Pick; + +function makeResp(data: unknown, status = 200): MockResponse { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(), + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + }; +} + +interface CommanderLikeCmd { + optsWithGlobals: () => { output: string; quiet: boolean }; +} + +function makeCmd(output = "json"): CommanderLikeCmd { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = ((c: string | Uint8Array) => { + if (typeof c === "string") chunks.push(c); + return true; + }) as typeof process.stdout.write; + try { + await fn(); + } finally { + process.stdout.write = orig; + } + return chunks.join(""); +} + +test("restCompressionStatus (via runCompressionStatus REST fallback) should surface settings.defaultMode as `strategy`, not a nonexistent `engine` field", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL | Request) => { + const u = String(url); + if (u.includes("/api/mcp/tools/call")) return makeResp({ error: "not mounted" }, 404); + if (u.includes("/api/settings/compression")) { + // Canonical server payload — NOTE: field is `defaultMode`, there is no `engine` key. + // src/lib/db/compression.ts COMPRESSION_MODES / GET route just returns getCompressionSettings(). + return makeResp({ enabled: true, defaultMode: "stacked" }); + } + if (u.includes("/api/context/combos")) return makeResp({ combos: [] }); + if (u.includes("/api/context/analytics")) return makeResp({ totalRequests: 0 }); + throw new Error(`unexpected fetch: ${u}`); + }) as typeof fetch; + + try { + const { runCompressionStatus } = await import( + "../../bin/cli/commands/compression.mjs" + ); + const out = await captureStdout(() => + runCompressionStatus({}, makeCmd("json") as unknown as Parameters[1]) + ); + const parsed = JSON.parse(out); + + // The server's actual field is `defaultMode: "stacked"`. The CLI's REST fallback + // must surface that as `strategy` (matching the MCP tool's contract in + // open-sse/mcp-server/tools/compressionTools.ts::handleCompressionStatus, which + // returns `strategy: settings.defaultMode || "standard"`), not a nonexistent + // `engine` field that is always null. + assert.equal( + parsed.strategy, + "stacked", + `expected REST fallback to expose settings.defaultMode as "strategy" (got: ${JSON.stringify(parsed)})` + ); + } finally { + globalThis.fetch = origFetch; + } +}); + +test("restSetEngine (via runCompressionEngineSet REST fallback) should PUT `defaultMode`, not the nonexistent `engine` field, and translate caveman->standard", async () => { + const origFetch = globalThis.fetch; + const putBodies: Record[] = []; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const u = String(url); + if (u.includes("/api/mcp/tools/call")) return makeResp({ error: "not mounted" }, 404); + if (u.includes("/api/settings/compression") && init?.method === "PUT") { + const body = init?.body ? JSON.parse(String(init.body)) : {}; + putBodies.push(body); + return makeResp({ enabled: true, defaultMode: body.defaultMode ?? body.engine }); + } + throw new Error(`unexpected fetch: ${u} ${init?.method}`); + }) as typeof fetch; + + try { + const { runCompressionEngineSet } = await import( + "../../bin/cli/commands/compression.mjs" + ); + await runCompressionEngineSet( + "caveman", + {}, + makeCmd("json") as unknown as Parameters[2] + ); + + assert.equal(putBodies.length, 1, "expected exactly one PUT to /api/settings/compression"); + const body = putBodies[0]; + + // Bug 1: CLI currently PUTs `{ engine: "standard" }`. The server's Zod schema + // (compressionSettingsUpdateSchema in src/shared/validation/compressionConfigSchemas.ts) + // is `.strict()` and has no `engine` key — only `defaultMode` — so this key is not + // even silently dropped, it fails validation server-side. The fix must send + // `defaultMode`, translating caveman->standard exactly like + // open-sse/mcp-server/tools/compressionTools.ts::handleSetCompressionEngine does + // (`args.engine === "caveman" ? "standard" : args.engine`). + assert.equal( + body.defaultMode, + "standard", + `expected PUT body to contain defaultMode:"standard" (caveman translated), got: ${JSON.stringify(body)}` + ); + assert.equal(body.engine, undefined, `PUT body must not contain a nonexistent "engine" key, got: ${JSON.stringify(body)}`); + } finally { + globalThis.fetch = origFetch; + } +}); + +test("output.mjs emit() table renderer must not print [object Object] for nested-object fields", async () => { + const { emit } = await import("../../bin/cli/output.mjs"); + + const chunks: string[] = []; + const origIsTTY = process.stdout.isTTY; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.isTTY = true; // force table format + process.stdout.write = ((c: string | Uint8Array) => { + if (typeof c === "string") chunks.push(c); + return true; + }) as typeof process.stdout.write; + + try { + emit([{ name: "combo-a", settings: { depth: 2, mode: "stacked" } }], { output: "table" }); + } finally { + process.stdout.write = origWrite; + process.stdout.isTTY = origIsTTY; + } + + const rendered = chunks.join(""); + assert.ok( + !rendered.includes("[object Object]"), + `table rendering must JSON-stringify nested object cells instead of printing "[object Object]"; got:\n${rendered}` + ); +});