From 48773face6fbde219b1e6b91ea085feeac07aad4 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:48:56 -0300 Subject: [PATCH] fix(combo): opt proactive-fallback compression into TV1 bail-out (no silent target drop) (#4228) Integrated into release/v3.8.29 (opt proactive-fallback compression into TV1 bail-out; baseline combo.ts 2597->2601). --- config/quality/file-size-baseline.json | 3 +- open-sse/services/combo.ts | 6 +- .../services/compression/strategySelector.ts | 6 ++ .../applycompression-bailout-optin.test.ts | 76 +++++++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/unit/compression/applycompression-bailout-optin.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 224d689a65..884e6c1bd7 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -55,6 +55,7 @@ "_rebaseline_2026_06_17_ctx_editing_relays": "Context Editing relay coverage + 400-fallback (F4.2/F4.3) own growth: base.ts 1244->1292 (+48 at the existing dispatch chokepoint: extend the injection gate to anthropic-compatible-cc-* relays with an expanded comment documenting the claude-web / generic-anthropic-compatible exclusions; the contextEditingDisabled loop flag; and the 400-fallback block that, when an upstream rejects context_management with a 400, strips the param, re-serializes + re-signs (CCH), and retries the same URL once). Both edits sit on the per-URL request-build/fetch path and cannot be extracted without splitting the executor's fetch loop. No new file warranted — the logic is a few lines tightly coupled to the in-loop transformedBody/bodyString/response locals.", "_rebaseline_2026_06_17_ctx_editing_telemetry": "Context Editing telemetry (F4.1) own growth: chatCore.ts 5875->5898 (+23 = a best-effort, Claude-only, non-streaming block at the existing non-streaming response handler that extracts the provider's server-side context-editing receipt from responseBody and records it under engine \"context-editing\"). The pure extractor (extractContextEditingTelemetry) lives in the existing small open-sse/config/contextEditing.ts and the DB writer (recordContextEditingTelemetry) in src/lib/db/compressionAnalytics.ts — both under cap. Cohesive at the response-handling chokepoint next to the existing attachCompressionUsageReceipt call; not extractable without hiding the receipt-recording boundary.", "_rebaseline_2026_06_17_duckduckgo_free": "Free web search (free-claude-code port, Fase 6) own growth: handlers/search.ts 1442->1546 (+104 = tryDuckDuckGoFreeProvider, a dedicated provider path mirroring the existing tryZaiMCPProvider special-case: the lite endpoint returns HTML, not the JSON the generic tryProvider()/normalizeResponse() flow expects, so it cannot reuse that path). The reusable parser + guarded fetch (parseDuckDuckGoLite, freeWebSearch) live in the new small open-sse/services/freeWebSearch.ts (well under cap). Cohesive at the per-provider dispatch chokepoint; not extractable without hiding the provider boundary.", + "_rebaseline_2026_06_18_4228_combo_fallback_bailout": "PR #4228 own growth: combo.ts 2597->2601 (+4 at the existing proactive-fallback applyCompression call in handleComboChat = opt into the TV1 bail-out so a throwing fallback engine is SKIPPED instead of propagating out of executeTarget and being swallowed as a 'Speculative task error' that silently drops the combo target). minGainPercent:0 keeps the advance behavior identical to the default path — this only adds skip-on-throw. The bailout option is threaded through the (non-frozen) strategySelector.ts applyCompression signature down to applyStackedCompression. Cohesive guard at the existing fallback-compression chokepoint; not extractable without hiding the call site.", "_rebaseline_2026_06_17_stream_recovery": "Stream recovery wiring (free-claude-code port, Fase 4) own growth: chatCore.ts 5898->5980 (+82 at the existing streaming-return chokepoint: read the resolved streamRecovery.enabled setting once; when ON, a reopenStream() thunk re-executes the SAME upstream — it closes over the ~15 per-attempt executor locals (executor/provider/modelToCall/bodyToSend/upstreamStream/execCreds/extendedContext/headers builders/onCredentialsRefreshed/skipUpstreamRetry/contextEditingEnabled) — and the body is wrapped with createRecoverableStream for transparent early-retry; OFF keeps the byte-identical wrapReadableStreamWithFinalize path). All reusable logic (HoldbackBuffer, createRecoverableStream, isRetryableStreamError, hasTerminalMarker) lives in the new open-sse/services/streamRecovery.ts (well under cap). Not extractable without hiding the per-attempt executor-dispatch boundary; opt-in (default OFF). Structural shrink of chatCore.ts tracked in #3501.", "cap": 800, "frozen": { @@ -84,7 +85,7 @@ "open-sse/services/batchProcessor.ts": 828, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, - "open-sse/services/combo.ts": 2597, + "open-sse/services/combo.ts": 2601, "open-sse/services/rateLimitManager.ts": 1035, "open-sse/services/tokenRefresh.ts": 1997, "open-sse/services/usage.ts": 3408, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index a4439085a0..98fd6174b3 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -1281,7 +1281,11 @@ export async function handleComboChat({ const compressionResult = applyCompression( attemptBody, config.fallbackCompressionMode as CompressionMode, - { model: modelStr } + // Opt into the TV1 bail-out so a throwing fallback engine is SKIPPED rather than + // propagating out of executeTarget and being swallowed as a "Speculative task + // error" (which silently drops this combo target). minGainPercent:0 keeps the + // advance behavior identical to the default path — this only adds skip-on-throw. + { model: modelStr, bailout: { enabled: true, minGainPercent: 0 } } ); if (compressionResult.compressed) { log.info( diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 548051f9e2..8f20000ee5 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -97,6 +97,12 @@ export function applyCompression( supportsVision?: boolean | null; config?: CompressionConfig; principalId?: string; + /** + * Opt into the TV1 stacked bail-out (skip-on-throw + min-gain). Default off keeps the + * legacy behavior. The combo proactive-fallback path enables it so a throwing engine is + * skipped instead of silently dropping the target. Flows through to applyStackedCompression. + */ + bailout?: BailoutConfig; } ): CompressionResult { if (mode === "off") { diff --git a/tests/unit/compression/applycompression-bailout-optin.test.ts b/tests/unit/compression/applycompression-bailout-optin.test.ts new file mode 100644 index 0000000000..55bb8308e9 --- /dev/null +++ b/tests/unit/compression/applycompression-bailout-optin.test.ts @@ -0,0 +1,76 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { applyCompression } from "../../../open-sse/services/compression/strategySelector.ts"; +import { registerCompressionEngine } from "../../../open-sse/services/compression/index.ts"; +import type { CompressionEngine } from "../../../open-sse/services/compression/engines/types.ts"; +import type { CompressionConfig } from "../../../open-sse/services/compression/types.ts"; + +// The combo proactive-fallback path calls applyCompression(stacked); a throwing engine there +// propagates and is swallowed as a "Speculative task error", silently dropping the target. The +// fix lets that caller opt into the TV1 bail-out (enabled, minGainPercent 0 = skip-on-throw +// without changing the min-gain advance behavior). This documents the opt-in contract. +const THROWING_ENGINE: CompressionEngine = { + id: "applycompress-bailout-throw", + name: "Throwing Test Engine", + description: "test fixture", + icon: "", + targets: ["messages"], + stackable: true, + stackPriority: 999, + metadata: { + id: "applycompress-bailout-throw", + name: "Throwing Test Engine", + description: "test", + inputScope: "messages", + targetLatencyMs: 1, + supportsPreview: false, + stable: false, + }, + apply() { + throw new Error("boom from fallback engine"); + }, + compress() { + throw new Error("boom from fallback engine"); + }, + getConfigSchema() { + return []; + }, + validateConfig() { + return { valid: true, errors: [] }; + }, +}; + +function stackedConfig(): CompressionConfig { + return { + stackedPipeline: [{ engine: "applycompress-bailout-throw" }], + } as unknown as CompressionConfig; +} + +describe("applyCompression — combo fallback bail-out opt-in", () => { + it("opting into bail-out skips a throwing engine instead of propagating (no silent drop)", () => { + registerCompressionEngine(THROWING_ENGINE); + const body = { messages: [{ role: "user", content: "hello world" }] }; + + let result: ReturnType | undefined; + assert.doesNotThrow(() => { + result = applyCompression(body, "stacked", { + config: stackedConfig(), + bailout: { enabled: true, minGainPercent: 0 }, + }); + }); + assert.ok(result, "returns a result instead of throwing"); + assert.equal(result!.compressed, false, "a crashing engine compresses nothing"); + }); + + it("without bail-out, a throwing engine still propagates (TV1 default unchanged)", () => { + registerCompressionEngine(THROWING_ENGINE); + const body = { messages: [{ role: "user", content: "hello world" }] }; + + assert.throws( + () => applyCompression(body, "stacked", { config: stackedConfig() }), + /boom from fallback engine/, + "the default (opt-out) path is unchanged — bail-out is strictly opt-in" + ); + }); +});