From 7735f3611a48ced4050fc75445dad3a418bbc4c4 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 11 Jun 2026 16:16:49 -0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(combo):=20stop=20premature=20context=20?= =?UTF-8?q?compaction=20=E2=80=94=20advertise=20real=20auto-combo=20window?= =?UTF-8?q?s=20+=20per-target=20compression=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto combos advertised limit.context=0 through the opencode plugin, which disables opencode's smart auto-compaction (overflow guard short-circuits on context===0); sessions then grew until chatCore's destructive purifyHistory silently dropped old messages — 'coding agent keeps forgetting things'. Separately, chatCore compressed DB-combo requests (incl. quota-shared pools) at min(...allTargets) even though it always executes with the concrete target's provider/model. - GET /api/combos/auto now serves context_length / max_output_tokens = MAX across the candidate pool (virtualFactory.computeAdvertisedLimits); MAX is safe because the auto-combo context pre-filter routes oversized requests to large-window candidates - opencode plugin maps the served limits; safe positive fallback (128000/8192) for older servers — never 0 - chatCore proactive compression uses the executing target's own window (contextManager.resolveComboContextLimit); min-of-targets kept only as a defensive fallback when the current provider/model has no specific limit TDD: tests/unit/auto-combo-context-advertising.test.ts (8) + @omniroute/opencode-plugin/tests/auto-combo-context.test.ts (3), RED→GREEN. Suites: plugin 257/257, vitest 146/146, affected unit files green; typecheck:core clean; lint clean; cycles clean. --- @omniroute/opencode-plugin/package.json | 2 +- @omniroute/opencode-plugin/src/index.ts | 29 ++- .../tests/auto-combo-context.test.ts | 74 ++++++ CHANGELOG.md | 1 + docs/routing/AUTO-COMBO.md | 2 + open-sse/handlers/chatCore.ts | 26 ++- open-sse/services/autoCombo/virtualFactory.ts | 53 +++++ open-sse/services/contextManager.ts | 60 ++++- src/app/api/combos/auto/route.ts | 4 + .../auto-combo-context-advertising.test.ts | 210 ++++++++++++++++++ 10 files changed, 444 insertions(+), 17 deletions(-) create mode 100644 @omniroute/opencode-plugin/tests/auto-combo-context.test.ts create mode 100644 tests/unit/auto-combo-context-advertising.test.ts diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index 8489bbd9e13..c36a77f011f 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -26,7 +26,7 @@ "scripts": { "build": "tsup", "clean": "rm -rf dist", - "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts", + "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts", "prepublishOnly": "npm run clean && npm run build && npm test" }, "keywords": [ diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 19aaf6cf21b..8ad06edfe69 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -1121,6 +1121,11 @@ export interface OmniRouteRawAutoCombo { candidatePool?: string[]; /** Number of candidates resolved at fetch time. */ candidateCount?: number; + /** MAX of candidates' context windows, served by newer OmniRoute builds. + * Absent on older servers — mapper falls back to a safe positive default. */ + context_length?: number; + /** MAX of candidates' max output tokens (same provenance as context_length). */ + max_output_tokens?: number; /** Whether this auto combo should be hidden from the picker. */ isHidden?: boolean; /** Auto-combo configuration. */ @@ -1211,24 +1216,42 @@ export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = } }; +/** Fallbacks when the server does not advertise auto-combo limits (older + * OmniRoute builds). MUST be positive: OpenCode's overflow guard treats + * `limit.context === 0` as "never overflow" and silently DISABLES smart + * auto-compaction, letting the session grow until the gateway's destructive + * history purge kicks in (the "agent keeps forgetting things" bug). */ +const AUTO_COMBO_FALLBACK_CONTEXT = 128_000; +const AUTO_COMBO_FALLBACK_OUTPUT = 8_192; + /** * Convert a raw auto combo into a static model entry for the OpenCode picker. * Auto combos have tool_call=true, reasoning=true by default (they route - * to capable models). Context/output limits are set to 0 since the actual - * limits depend on which provider is selected at runtime. + * to capable models). Context/output limits come from the server (MAX of + * the candidate pool's windows — the gateway's context pre-filter routes + * oversized requests to large-window candidates); a safe positive fallback + * applies when the server omits them. Never 0. */ export function mapAutoComboToStaticEntry( autoCombo: OmniRouteRawAutoCombo, ): OmniRouteStaticModelEntry { const variant = autoCombo.variant; const name = formatAutoComboName(variant, autoCombo.candidateCount); + const context = + typeof autoCombo.context_length === "number" && autoCombo.context_length > 0 + ? autoCombo.context_length + : AUTO_COMBO_FALLBACK_CONTEXT; + const output = + typeof autoCombo.max_output_tokens === "number" && autoCombo.max_output_tokens > 0 + ? autoCombo.max_output_tokens + : AUTO_COMBO_FALLBACK_OUTPUT; return { name, attachment: false, reasoning: true, temperature: true, tool_call: true, - limit: { context: 0, output: 0 }, + limit: { context, output }, modalities: { input: ["text"], output: ["text"], diff --git a/@omniroute/opencode-plugin/tests/auto-combo-context.test.ts b/@omniroute/opencode-plugin/tests/auto-combo-context.test.ts new file mode 100644 index 00000000000..73213dde023 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/auto-combo-context.test.ts @@ -0,0 +1,74 @@ +/** + * TDD regression — auto combos must never advertise `limit.context: 0`. + * + * opencode's overflow guard (packages/opencode/src/session/overflow.ts) + * short-circuits when `model.limit.context === 0`: + * + * if (input.model.limit.context === 0) return false // never overflow + * + * so a zero context silently DISABLES opencode's smart auto-compaction for + * auto combos. The session then grows unbounded until OmniRoute's + * server-side purifyHistory() destructively drops old messages — the + * "coding agent keeps forgetting things" bug. + * + * Fix under test: mapAutoComboToStaticEntry consumes the context_length / + * max_output_tokens now served by GET /api/combos/auto, and falls back to a + * safe positive default (128000 / 8192) for older servers that do not send + * the fields yet. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { mapAutoComboToStaticEntry } from "../src/index.ts"; +import type { OmniRouteRawAutoCombo } from "../src/index.ts"; + +test("uses server-provided context_length and max_output_tokens", () => { + const raw = { + id: "auto/coding", + name: "Auto Coding", + variant: "coding", + candidateCount: 5, + context_length: 1048576, + max_output_tokens: 65536, + } as OmniRouteRawAutoCombo; + + const entry = mapAutoComboToStaticEntry(raw); + assert.equal(entry.limit?.context, 1048576); + assert.equal(entry.limit?.output, 65536); +}); + +test("falls back to a safe positive default when the server omits limits (old servers)", () => { + const raw = { + id: "auto", + name: "Auto", + candidateCount: 3, + } as OmniRouteRawAutoCombo; + + const entry = mapAutoComboToStaticEntry(raw); + assert.ok( + typeof entry.limit?.context === "number" && entry.limit.context > 0, + `context must be a positive number (never 0 — zero disables opencode auto-compaction), got ${entry.limit?.context}` + ); + assert.ok( + typeof entry.limit?.output === "number" && entry.limit.output > 0, + `output must be a positive number, got ${entry.limit?.output}` + ); +}); + +test("ignores non-positive server values and keeps the safe fallback", () => { + const raw = { + id: "auto/fast", + name: "Auto Fast", + variant: "fast", + candidateCount: 2, + context_length: 0, + max_output_tokens: -1, + } as OmniRouteRawAutoCombo; + + const entry = mapAutoComboToStaticEntry(raw); + assert.ok( + typeof entry.limit?.context === "number" && entry.limit.context > 0, + "zero/negative server values must not propagate" + ); + assert.ok(typeof entry.limit?.output === "number" && entry.limit.output > 0); +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index c66ae3e0539..0a34999cc0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ ### 🔧 Bug Fixes +- **Combos / Auto-Combo: premature context compaction ("agent keeps forgetting things")**: two related context-window bugs fixed. (1) `GET /api/combos/auto` now advertises `context_length` / `max_output_tokens` (MAX across the candidate pool — safe because the auto-combo context pre-filter routes oversized requests to large-window candidates), and the opencode plugin consumes them instead of hardcoding `limit: { context: 0 }` — a zero context silently disables opencode's smart auto-compaction, letting sessions grow until the gateway's destructive history purge kicks in. (2) chatCore's proactive compression for DB combos (incl. quota-shared pools) no longer compresses at `min(...allTargets)`: it now uses the EXECUTING target's own window (`resolveComboContextLimit`), keeping min-of-targets only as a defensive fallback when the current provider/model resolves no specific limit. TDD: 8 server tests (`tests/unit/auto-combo-context-advertising.test.ts`) + 3 plugin tests (`tests/auto-combo-context.test.ts`). - **OpenCode Free / passthrough**: "Test all models" now respects "Auto-hide failed models" and switches the list to the visible filter so hidden models actually disappear (#3610). Three related bugs fixed: `autoHideFailed` is now threaded from the outer component into `PassthroughModelsSection` via a prop (single shared checkbox); the `/api/models/test-all` request body now includes `autoHideFailed: true` so the server persists the hide; and after the loop, `visibilityFilter` is switched to `"visible"` when ≥1 model was hidden. Two pure-function helpers (`buildPassthroughTestBody`, `shouldSwitchToVisibleFilter`) extracted to `providerPageHelpers.ts` with 7 unit tests. - **Resilience**: clear stale transient connection cooldowns on startup so a prior unclean crash no longer makes every request time out at 120s after restart (#3625) diff --git a/docs/routing/AUTO-COMBO.md b/docs/routing/AUTO-COMBO.md index c470a407d07..94596e756cd 100644 --- a/docs/routing/AUTO-COMBO.md +++ b/docs/routing/AUTO-COMBO.md @@ -182,6 +182,8 @@ There is **no dedicated `POST /api/combos/auto` endpoint** — Auto-Combo is con 2. **Persisted combo with `strategy: "auto"`:** Create a regular combo via `POST /api/combos` and set `strategy: "auto"` plus `config.auto.weights` / `config.auto.candidatePool`. The same scoring engine is used; the combo is stored in `combos` and reusable by ID. +For discovery, `GET /api/combos/auto` lists every variant with its resolved candidate pool plus `context_length` / `max_output_tokens` — the MAX across the candidate pool's windows. Clients (e.g. the opencode plugin) must advertise these values instead of `0`: a zero context disables opencode's auto-compaction entirely, letting sessions grow until the gateway's history purge destroys context. MAX is safe to advertise because the auto-combo context pre-filter routes oversized requests to large-window candidates. + ```bash # Zero-config usage (no combo creation) curl -X POST http://localhost:20128/v1/chat/completions \ diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index cf8008f5c45..5198a21a7d6 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -192,7 +192,12 @@ import { getModelFamily, } from "../services/modelFamilyFallback.ts"; import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts"; -import { compressContext, estimateTokens, getTokenLimit } from "../services/contextManager.ts"; +import { + compressContext, + estimateTokens, + getTokenLimit, + resolveComboContextLimit, +} from "../services/contextManager.ts"; import { getBackgroundTaskReason, getDegradedModel, @@ -2741,21 +2746,30 @@ export async function handleChatCore({ if (!comboConfig && comboName.startsWith("combo/")) { comboConfig = await getComboByName(comboName.substring(6)); } + let comboTargetLimits: number[] = []; if (comboConfig) { const allCombosData = await getCombosCached(); const targets = resolveComboTargets( comboConfig as unknown as { name: string; models: unknown[] }, allCombosData as unknown as { name: string; models: unknown[] }[] ); - const limits = targets.map((t: { modelStr?: string }) => { + comboTargetLimits = targets.map((t: { modelStr?: string }) => { const parsed = parseModel(t.modelStr); return getTokenLimit(parsed.provider, parsed.model); }); - if (limits.length > 0) { - contextLimit = Math.min(...limits); - log?.info?.("CONTEXT", `Combo min limit: ${contextLimit}`); - } } + // chatCore executes per concrete target (handleSingleModel resolves + // provider/effectiveModel before delegating). Compress against THIS + // target's window; min(...allTargets) is only a defensive fallback — + // the old unconditional min compressed a 1M-target request at the + // smallest sibling's window ("agent keeps forgetting things"). + const resolved = resolveComboContextLimit({ + provider, + model: effectiveModel, + comboTargetLimits, + }); + contextLimit = resolved.limit; + log?.info?.("CONTEXT", `Combo context limit: ${resolved.limit} (source=${resolved.source})`); } catch (err) { log?.warn?.("CONTEXT", "Failed to resolve combo limits for compression: " + err); } diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index eb743f99596..e708fe70f71 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -8,6 +8,8 @@ import type { ConnectionFields } from "@/lib/db/encryption"; import { NOAUTH_PROVIDERS } from "@/shared/constants/providers"; import { hasUsableWebSessionCredential } from "@/shared/providers/webSessionCredentials"; import { defaultLogger as log } from "@omniroute/open-sse/utils/logger"; +import { getTokenLimit } from "../contextManager"; +import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; /** Minimal connection shape needed for virtual auto-combo factory */ interface VirtualFactoryConn extends ConnectionFields { @@ -45,6 +47,11 @@ type VirtualAutoCombo = AutoComboConfig & { weight: number; label: string; }>; + /** MAX of candidates' context windows — safe to advertise because the + * auto-combo context pre-filter routes oversized requests to large-window + * candidates. null when the pool is empty. */ + advertisedContextLength: number | null; + advertisedMaxOutputTokens: number | null; autoConfig: { candidatePool: string[]; weights: ScoringWeights; @@ -156,6 +163,46 @@ function getNoAuthCandidates(excludedProviders: Set): VirtualAutoComboCa * Creates a virtual AutoCombo configuration dynamically based on connected providers and a specified variant. * This combo is not persisted in the DB. */ +/** + * Aggregate the context window / max output to ADVERTISE for an auto combo. + * + * MAX across candidates (not min): the auto-combo context pre-filter + * (combo.ts::filterTargetsByRequestCompatibility + the estimated-tokens + * pre-filter) already routes oversized requests away from small-window + * candidates, so advertising the largest window lets clients (e.g. opencode) + * keep their smart auto-compaction calibrated to the best candidate instead + * of compacting prematurely — or, worse, receiving 0 and disabling + * compaction entirely (the "agent keeps forgetting things" bug). + * + * Unknown candidates resolve through getTokenLimit()'s fallback chain, so a + * non-empty pool always yields a positive contextLength. + */ +export function computeAdvertisedLimits(candidates: Array<{ provider: string; model: string }>): { + contextLength: number | null; + maxOutputTokens: number | null; +} { + if (!Array.isArray(candidates) || candidates.length === 0) { + return { contextLength: null, maxOutputTokens: null }; + } + + let contextLength: number | null = null; + let maxOutputTokens: number | null = null; + for (const candidate of candidates) { + const limit = getTokenLimit(candidate.provider, candidate.model); + if (Number.isFinite(limit) && limit > 0) { + contextLength = contextLength === null ? limit : Math.max(contextLength, limit); + } + const output = getResolvedModelCapabilities({ + provider: candidate.provider, + model: candidate.model, + }).maxOutputTokens; + if (typeof output === "number" && Number.isFinite(output) && output > 0) { + maxOutputTokens = maxOutputTokens === null ? output : Math.max(maxOutputTokens, output); + } + } + return { contextLength, maxOutputTokens }; +} + export async function createVirtualAutoCombo( variant: AutoVariant | undefined ): Promise { @@ -209,6 +256,8 @@ export async function createVirtualAutoCombo( routerStrategy: autoConfig.routerStrategy, autoConfig, config: { auto: autoConfig }, + advertisedContextLength: null, + advertisedMaxOutputTokens: null, }; } @@ -259,6 +308,8 @@ export async function createVirtualAutoCombo( routerStrategy, }; + const advertisedLimits = computeAdvertisedLimits(candidatePool); + return { id: `virtual-auto-${variant || "default"}`, name: `Auto ${variant || "Default"}`, @@ -271,5 +322,7 @@ export async function createVirtualAutoCombo( routerStrategy, autoConfig, config: { auto: autoConfig }, + advertisedContextLength: advertisedLimits.contextLength, + advertisedMaxOutputTokens: advertisedLimits.maxOutputTokens, }; } diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index baad8b02040..e2c49171327 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -61,27 +61,40 @@ export function estimateTokens(text: string | object | null | undefined): number * Priority: Env override > models.dev DB > Registry defaultContextLength > DEFAULT_LIMITS */ export function getTokenLimit(provider: string, model: string | null = null): number { + return resolveTokenLimit(provider, model).limit; +} + +/** + * Same chain as getTokenLimit, but also reports whether the limit came from + * a provider/model-specific source (env override, synced DB, registry, + * name heuristic, curated per-provider default) or only from the generic + * catch-all default. + */ +function resolveTokenLimit( + provider: string, + model: string | null = null +): { limit: number; specific: boolean } { // 1. Check environment variable override first const envOverride = getEnvOverride(provider); - if (envOverride) return envOverride; + if (envOverride) return { limit: envOverride, specific: true }; // 2. Check models.dev synced DB for per-model context limit if (model) { const dbLimit = getModelContextLimit(provider, model); - if (dbLimit && dbLimit > 0) return dbLimit; + if (dbLimit && dbLimit > 0) return { limit: dbLimit, specific: true }; } // 3. Check registry for provider default const registryEntry = REGISTRY[provider]; if (registryEntry?.defaultContextLength) { - return registryEntry.defaultContextLength; + return { limit: registryEntry.defaultContextLength, specific: true }; } // 4. Check if model name hints at a known limit if (model) { const lower = model.toLowerCase(); - if (lower.includes("claude")) return DEFAULT_LIMITS.claude; - if (lower.includes("gemini")) return DEFAULT_LIMITS.gemini; + if (lower.includes("claude")) return { limit: DEFAULT_LIMITS.claude, specific: true }; + if (lower.includes("gemini")) return { limit: DEFAULT_LIMITS.gemini, specific: true }; if ( lower.includes("gpt") || lower.includes("o1") || @@ -89,11 +102,44 @@ export function getTokenLimit(provider: string, model: string | null = null): nu lower.includes("o4") || lower.includes("codex") ) - return DEFAULT_LIMITS.codex; + return { limit: DEFAULT_LIMITS.codex, specific: true }; } // 5. Fallback to DEFAULT_LIMITS or default - return DEFAULT_LIMITS[provider] || DEFAULT_LIMITS.default; + if (DEFAULT_LIMITS[provider]) return { limit: DEFAULT_LIMITS[provider], specific: true }; + return { limit: DEFAULT_LIMITS.default, specific: false }; +} + +/** + * Resolve the context limit to use for proactive compression of a COMBO + * request. + * + * chatCore always executes with the CONCRETE target's provider/model + * (handleSingleModel resolves the target before delegating), so the + * executing target's own limit is authoritative. Using min(...allTargets) + * here — the previous behavior — compressed at the smallest sibling's + * window even when running on the largest target, destructively purging + * history long before the real window filled ("agent keeps forgetting"). + * + * min(...comboTargetLimits) is kept only as a defensive fallback for the + * case where the current provider/model resolves no specific limit at all. + */ +export function resolveComboContextLimit(options: { + provider: string; + model: string | null; + comboTargetLimits: number[]; +}): { limit: number; source: "target" | "combo-min" | "fallback" } { + const own = resolveTokenLimit(options.provider, options.model ?? null); + if (own.specific) { + return { limit: own.limit, source: "target" }; + } + const knownTargets = (options.comboTargetLimits || []).filter( + (value) => Number.isFinite(value) && value > 0 + ); + if (knownTargets.length > 0) { + return { limit: Math.min(...knownTargets), source: "combo-min" }; + } + return { limit: own.limit, source: "fallback" }; } /** diff --git a/src/app/api/combos/auto/route.ts b/src/app/api/combos/auto/route.ts index 87ff185b22e..7c282dd0467 100644 --- a/src/app/api/combos/auto/route.ts +++ b/src/app/api/combos/auto/route.ts @@ -34,6 +34,10 @@ export async function GET(request: Request) { isHidden: false, candidatePool: virtual.candidatePool ?? [], candidateCount: virtual.candidatePool?.length ?? 0, + // MAX of candidates' windows — consumers (opencode plugin) need a + // real value here: advertising 0 disables client auto-compaction. + context_length: virtual.advertisedContextLength ?? null, + max_output_tokens: virtual.advertisedMaxOutputTokens ?? null, config: virtual.config ?? {}, }); } catch { diff --git a/tests/unit/auto-combo-context-advertising.test.ts b/tests/unit/auto-combo-context-advertising.test.ts new file mode 100644 index 00000000000..e316cdae523 --- /dev/null +++ b/tests/unit/auto-combo-context-advertising.test.ts @@ -0,0 +1,210 @@ +/** + * TDD regression tests — auto-combo context-window advertising + per-target + * combo compression limit (the "premature auto compaction" bug). + * + * Bug chain (discussion report: coding agents "keep forgetting things"): + * 1. /api/combos/auto never exposed context_length, so the opencode plugin + * advertised `limit: { context: 0 }` for auto combos. opencode disables + * its smart auto-compaction entirely when context === 0, letting the + * conversation grow until OmniRoute's destructive purifyHistory() drops + * old messages silently. + * 2. chatCore's proactive-compression block overrode the per-target context + * limit with min(...allComboTargets) even though chatCore always executes + * with the CONCRETE target's provider/model (handleSingleModel resolves + * the target before calling chatCore) — compressing at the smallest + * target's window while running on the largest target. + * + * Fixes under test: + * - virtualFactory.computeAdvertisedLimits(): MAX of candidates' known + * context windows (the auto-combo context pre-filter routes oversized + * requests to large-window candidates, so MAX is safe to advertise). + * - GET /api/combos/auto includes context_length / max_output_tokens. + * - contextManager.resolveComboContextLimit(): prefers the executing + * target's own limit; min(...targets) only as a defensive fallback when + * the current provider/model resolves no specific limit. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-auto-combo-ctx-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "auto-combo-ctx-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); + +const virtualFactory = await import("../../open-sse/services/autoCombo/virtualFactory.ts"); +const contextManager = await import("../../open-sse/services/contextManager.ts"); +const combosAutoRoute = await import("../../src/app/api/combos/auto/route.ts"); + +test.after(() => { + core.resetDbInstance(); + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +// ── virtualFactory.computeAdvertisedLimits ─────────────────────────────────── + +test("computeAdvertisedLimits returns MAX of candidates' known context windows", () => { + const { computeAdvertisedLimits } = virtualFactory as unknown as { + computeAdvertisedLimits: (candidates: Array<{ provider: string; model: string }>) => { + contextLength: number | null; + maxOutputTokens: number | null; + }; + }; + assert.equal( + typeof computeAdvertisedLimits, + "function", + "virtualFactory should export computeAdvertisedLimits()" + ); + + // gemini has registry defaultContextLength=1048576; claude has 200000. + const result = computeAdvertisedLimits([ + { provider: "claude", model: "claude-sonnet-4-6" }, + { provider: "gemini", model: "gemini-2.5-pro" }, + ]); + assert.equal(result.contextLength, 1048576, "MAX of candidate windows should win"); + assert.ok( + typeof result.maxOutputTokens === "number" && result.maxOutputTokens > 0, + "maxOutputTokens should be a positive number" + ); +}); + +test("computeAdvertisedLimits returns null limits for an empty candidate pool", () => { + const { computeAdvertisedLimits } = virtualFactory as unknown as { + computeAdvertisedLimits: (candidates: Array<{ provider: string; model: string }>) => { + contextLength: number | null; + maxOutputTokens: number | null; + }; + }; + const result = computeAdvertisedLimits([]); + assert.equal(result.contextLength, null); + assert.equal(result.maxOutputTokens, null); +}); + +test("computeAdvertisedLimits never returns 0 for a non-empty pool (unknown models fall back)", () => { + const { computeAdvertisedLimits } = virtualFactory as unknown as { + computeAdvertisedLimits: (candidates: Array<{ provider: string; model: string }>) => { + contextLength: number | null; + maxOutputTokens: number | null; + }; + }; + const result = computeAdvertisedLimits([ + { provider: "totally-unknown-provider", model: "mystery-model" }, + ]); + assert.ok( + typeof result.contextLength === "number" && result.contextLength > 0, + `unknown candidates should fall back to a positive default, got ${result.contextLength}` + ); +}); + +// ── GET /api/combos/auto advertises context_length ────────────────────────── + +test("GET /api/combos/auto includes positive context_length for combos with candidates", async () => { + await settingsDb.updateSettings({ requireLogin: false }); + + const req = new Request("http://localhost/api/combos/auto", { method: "GET" }); + const res = await combosAutoRoute.GET(req as never); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.ok(Array.isArray(body.combos), "body.combos should be an array"); + assert.ok(body.combos.length > 0, "should list at least the default auto combo"); + + for (const combo of body.combos) { + if ((combo.candidateCount ?? 0) > 0) { + assert.ok( + typeof combo.context_length === "number" && combo.context_length > 0, + `combo ${combo.id} with ${combo.candidateCount} candidates must advertise a positive context_length, got ${combo.context_length}` + ); + assert.ok( + typeof combo.max_output_tokens === "number" && combo.max_output_tokens > 0, + `combo ${combo.id} must advertise a positive max_output_tokens, got ${combo.max_output_tokens}` + ); + } + } +}); + +// ── contextManager.resolveComboContextLimit (per-target compression limit) ── + +test("resolveComboContextLimit prefers the executing target's own limit over combo min", () => { + const { resolveComboContextLimit } = contextManager as unknown as { + resolveComboContextLimit: (opts: { + provider: string; + model: string | null; + comboTargetLimits: number[]; + }) => { limit: number; source: string }; + }; + assert.equal( + typeof resolveComboContextLimit, + "function", + "contextManager should export resolveComboContextLimit()" + ); + + // Executing on gemini (1048576 provider default) while the combo also has + // a tiny 32k target: compression must use the EXECUTING target's window. + const result = resolveComboContextLimit({ + provider: "gemini", + model: "gemini-2.5-pro", + comboTargetLimits: [32000, 1048576], + }); + assert.equal(result.limit, 1048576, "must not regress to min(...targets) on a known target"); + assert.equal(result.source, "target"); +}); + +test("resolveComboContextLimit regression: claude target must not be compressed at an 8k sibling", () => { + const { resolveComboContextLimit } = contextManager as unknown as { + resolveComboContextLimit: (opts: { + provider: string; + model: string | null; + comboTargetLimits: number[]; + }) => { limit: number; source: string }; + }; + const result = resolveComboContextLimit({ + provider: "claude", + model: "claude-sonnet-4-6", + comboTargetLimits: [8000], + }); + assert.equal(result.limit, 200000); + assert.equal(result.source, "target"); +}); + +test("resolveComboContextLimit falls back to combo min when the target has no specific limit", () => { + const { resolveComboContextLimit } = contextManager as unknown as { + resolveComboContextLimit: (opts: { + provider: string; + model: string | null; + comboTargetLimits: number[]; + }) => { limit: number; source: string }; + }; + const result = resolveComboContextLimit({ + provider: "totally-unknown-provider", + model: "mystery-model", + comboTargetLimits: [32000, 200000], + }); + assert.equal(result.limit, 32000, "unknown target should defensively use min of combo targets"); + assert.equal(result.source, "combo-min"); +}); + +test("resolveComboContextLimit uses generic fallback when nothing else is known", () => { + const { resolveComboContextLimit } = contextManager as unknown as { + resolveComboContextLimit: (opts: { + provider: string; + model: string | null; + comboTargetLimits: number[]; + }) => { limit: number; source: string }; + }; + const result = resolveComboContextLimit({ + provider: "totally-unknown-provider", + model: "mystery-model", + comboTargetLimits: [], + }); + assert.equal(result.limit, 128000, "generic default fallback"); + assert.equal(result.source, "fallback"); +}); From 750fbe3556df9dc3cfbd77046441dfdc06300413 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 11 Jun 2026 16:18:14 -0300 Subject: [PATCH 2/2] docs(changelog): reference PR #3680 in combo context-compaction entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a34999cc0a..84e603e077c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ ### 🔧 Bug Fixes -- **Combos / Auto-Combo: premature context compaction ("agent keeps forgetting things")**: two related context-window bugs fixed. (1) `GET /api/combos/auto` now advertises `context_length` / `max_output_tokens` (MAX across the candidate pool — safe because the auto-combo context pre-filter routes oversized requests to large-window candidates), and the opencode plugin consumes them instead of hardcoding `limit: { context: 0 }` — a zero context silently disables opencode's smart auto-compaction, letting sessions grow until the gateway's destructive history purge kicks in. (2) chatCore's proactive compression for DB combos (incl. quota-shared pools) no longer compresses at `min(...allTargets)`: it now uses the EXECUTING target's own window (`resolveComboContextLimit`), keeping min-of-targets only as a defensive fallback when the current provider/model resolves no specific limit. TDD: 8 server tests (`tests/unit/auto-combo-context-advertising.test.ts`) + 3 plugin tests (`tests/auto-combo-context.test.ts`). +- **Combos / Auto-Combo: premature context compaction ("agent keeps forgetting things")** ([#3680]): two related context-window bugs fixed. (1) `GET /api/combos/auto` now advertises `context_length` / `max_output_tokens` (MAX across the candidate pool — safe because the auto-combo context pre-filter routes oversized requests to large-window candidates), and the opencode plugin consumes them instead of hardcoding `limit: { context: 0 }` — a zero context silently disables opencode's smart auto-compaction, letting sessions grow until the gateway's destructive history purge kicks in. (2) chatCore's proactive compression for DB combos (incl. quota-shared pools) no longer compresses at `min(...allTargets)`: it now uses the EXECUTING target's own window (`resolveComboContextLimit`), keeping min-of-targets only as a defensive fallback when the current provider/model resolves no specific limit. TDD: 8 server tests (`tests/unit/auto-combo-context-advertising.test.ts`) + 3 plugin tests (`tests/auto-combo-context.test.ts`). - **OpenCode Free / passthrough**: "Test all models" now respects "Auto-hide failed models" and switches the list to the visible filter so hidden models actually disappear (#3610). Three related bugs fixed: `autoHideFailed` is now threaded from the outer component into `PassthroughModelsSection` via a prop (single shared checkbox); the `/api/models/test-all` request body now includes `autoHideFailed: true` so the server persists the hide; and after the loop, `visibilityFilter` is switched to `"visible"` when ≥1 model was hidden. Two pure-function helpers (`buildPassthroughTestBody`, `shouldSwitchToVisibleFilter`) extracted to `providerPageHelpers.ts` with 7 unit tests. - **Resilience**: clear stale transient connection cooldowns on startup so a prior unclean crash no longer makes every request time out at 120s after restart (#3625)