From 1044821bda4e67e014b0d0356d2917d50b05220c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 07:47:34 -0300 Subject: [PATCH] feat(combo): add option to disable session stickiness (#6168) (#6252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(combo): add option to disable session stickiness (#6168) — per-combo/global, precedence config→settings→false (preserves #3825). TDD guard combo-disable-session-stickiness.test.ts (8/8). Base-reds only. Integrated into release/v3.8.45. (thanks @RCrushMe) --- CHANGELOG.md | 1 + open-sse/services/combo.ts | 37 ++- open-sse/services/combo/sessionStickiness.ts | 20 ++ src/app/(dashboard)/dashboard/combos/page.tsx | 48 ++++ .../settings/components/ComboDefaultsTab.tsx | 37 ++- src/i18n/messages/en.json | 12 +- src/lib/db/settings.ts | 1 + src/shared/validation/schemas/combo.ts | 6 + src/shared/validation/settingsSchemas.ts | 2 + .../combo-disable-session-stickiness.test.ts | 233 ++++++++++++++++++ 10 files changed, 385 insertions(+), 12 deletions(-) create mode 100644 tests/unit/combo-disable-session-stickiness.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ff5708ec..e005c4c151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **feat(providers):** route the built-in **agentrouter** through the dynamic Claude-Code wire image ([#6056](https://github.com/diegosouzapw/OmniRoute/issues/6056)) — a small static allow-set (`CC_WIRE_IMAGE_BUILTINS` in `open-sse/services/ccWireImageBuiltins.ts`), consulted by `isClaudeCodeCompatible` / `isClaudeCodeCompatibleProvider` / `applyFingerprint`, makes agentrouter adopt the CC wire-image headers + fingerprint **while guarding the CC baseUrl/auth branches** so it keeps its own registry `baseUrl` and `x-api-key` auth. Regression guard: `tests/unit/agentrouter-cc-wire-image.test.ts` (asserts the wire image is applied AND agentrouter's baseUrl/auth are preserved). Live WAF-acceptance against agentrouter.org is a VPS validation follow-up (Hard Rule #18). - **feat(providers):** **bulk-add API keys for Cloudflare Workers AI** ([#6174](https://github.com/diegosouzapw/OmniRoute/issues/6174)) — `cloudflare-ai` is removed from the bulk-add exclusion list and the bulk parser gains a 3-field `name|accountId|apiKey` mode; the bulk route now builds a **per-entry** `providerSpecificData` so each key carries its own `accountId` (fixing the previous shared-object reuse), and both the create + key-validation paths receive it. Regression guard: `tests/unit/bulk-api-key-parser-cloudflare.test.ts`. (thanks @muflifadla38) - **feat(dashboard):** routing/settings UX clarity ([#6147](https://github.com/diegosouzapw/OmniRoute/issues/6147)) — (1) weighted combos show the **effective routing share %** next to each weight when weights don't sum to 100 (`WeightTotalBar.tsx`); (2) the status widget's user-facing **"Cloud Sync" label is renamed** to "Remote Settings Sync" (`CloudSyncStatus.tsx`; internal ids/state untouched); (3) built-in providers gain an **opt-in advanced base-URL override** (`isBaseUrlOverrideEligibleProvider`, hidden behind an "Advanced" toggle, reusing the existing `providerSpecificData.baseUrl` persistence — not globally widened). Regression guard: `tests/unit/routing-settings-ux-6147.test.ts`. +- **feat(combo):** add an option to **disable session stickiness**, per-combo or globally — round-robin / random combos can rotate to a different connection on every request instead of pinning a whole conversation to one connection by its first-message hash. Resolution precedence per-combo `config.disableSessionStickiness` → global `settings.disableSessionStickiness` → default `false` (preserves the #3825 prompt-cache/504 fix); gates **both** stickiness call sites in `open-sse/services/combo.ts`. Exposed as a global toggle (Combo Defaults) and a per-combo Inherit/on/off control. ([#6168](https://github.com/diegosouzapw/OmniRoute/issues/6168)) Regression guard: `tests/unit/combo-disable-session-stickiness.test.ts`. (thanks @RCrushMe) ### 🐛 Bug Fixes diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index a86bf5d90f..82543d0e3b 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -58,7 +58,11 @@ import { handlePipelineCombo, buildPipelineResponse } from "./autoCombo/pipeline import { type ProviderCandidate } from "./autoCombo/scoring.ts"; import { estimateTokens } from "./contextManager.ts"; import { getSessionConnection } from "./sessionManager.ts"; -import { applySessionStickiness, recordStickyBinding } from "./combo/sessionStickiness.ts"; +import { + applySessionStickiness, + recordStickyBinding, + resolveDisableSessionStickiness, +} from "./combo/sessionStickiness.ts"; import { selectQuotaShareTarget } from "./combo/quotaShareStrategy.ts"; import { makeConnectionConcurrencyResolver, lookupPositiveCap } from "./combo/concurrencyCaps.ts"; import { acquireQuotaShareConcurrencySlot } from "./combo/quotaShareConcurrency.ts"; @@ -1069,10 +1073,20 @@ export async function handleComboChat({ apiKeyAllowedConnections, }); } - const _sticky = await applySessionStickiness( - orderedTargets, - body.messages as Array<{ role?: string; content?: unknown }> + // #6168: session stickiness opt-out. Per-combo `config.disableSessionStickiness` + // overrides the global `settings.disableSessionStickiness` fallback (default false, + // preserving the #3825 prompt-cache/504 fix). When disabled, skip the reorder and + // treat the result as a no-op so the recordStickyBinding write-back below is skipped. + const disableSessionStickiness = resolveDisableSessionStickiness( + config as Record | null | undefined, + settings as Record | null | undefined ); + const _sticky = disableSessionStickiness + ? ({ targets: orderedTargets, messageHash: null, stuck: false } as const) + : await applySessionStickiness( + orderedTargets, + body.messages as Array<{ role?: string; content?: unknown }> + ); orderedTargets = _sticky.targets; orderedTargets = orderTargetsByEvalScores(orderedTargets, config.evalRouting, log); orderedTargets = filterTargetsByRequestCompatibility(orderedTargets, body, log); @@ -2389,10 +2403,19 @@ async function handleRoundRobinCombo({ // call — so sessionless RR combos rotated every turn, busting the upstream prompt-cache. // Reuse the SAME mechanism: start the rotation at the conversation's sticky connection // (the loop still falls through to the other targets on failure → failover preserved). - const _rrSessionSticky = await applySessionStickiness( - filteredTargets, - body?.messages as Array<{ role?: string; content?: unknown }> + // #6168: honor the session-stickiness opt-out here too, otherwise round-robin would + // still pin the conversation even when the flag is set. Per-combo `config` overrides + // the global `settings.disableSessionStickiness` fallback (default false). + const disableSessionStickiness = resolveDisableSessionStickiness( + config as Record | null | undefined, + settings as Record | null | undefined ); + const _rrSessionSticky = disableSessionStickiness + ? ({ targets: filteredTargets, messageHash: null, stuck: false } as const) + : await applySessionStickiness( + filteredTargets, + body?.messages as Array<{ role?: string; content?: unknown }> + ); let rrStartIndex = startIndex; if (_rrSessionSticky.stuck) { const stickyIdx = filteredTargets.findIndex( diff --git a/open-sse/services/combo/sessionStickiness.ts b/open-sse/services/combo/sessionStickiness.ts index 1b81548106..5bb8779a94 100644 --- a/open-sse/services/combo/sessionStickiness.ts +++ b/open-sse/services/combo/sessionStickiness.ts @@ -191,6 +191,26 @@ export function clearAllStickyBindings(): void { stickyMap.clear(); } +/** + * #6168: resolve the session-stickiness opt-out for a combo request. + * + * Precedence (mirrors the `stickyRoundRobinLimit` resolution in combo.ts): + * per-combo `config.disableSessionStickiness` (boolean) → + * global `settings.disableSessionStickiness` (boolean) → + * default `false`. + * + * Default `false` preserves the #3825 prompt-cache/504 fix — only an explicit + * `true` at either level disables stickiness. + */ +export function resolveDisableSessionStickiness( + config: Record | null | undefined, + settings: Record | null | undefined +): boolean { + const perCombo = config?.disableSessionStickiness; + if (typeof perCombo === "boolean") return perCombo; + return settings?.disableSessionStickiness === true; +} + // ─── Core: apply stickiness to an ordered target list ──────────────────────── export interface ApplyStickinessResult { diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 5f546db165..c5c068000c 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -3972,6 +3972,54 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo + {/* #6168: per-combo session-stickiness override (tri-state so it can + force ON or OFF regardless of the global default; blank = inherit). */} +
+ + +
{strategy === "context-relay" && (
diff --git a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx index bf83cbd4aa..8544506287 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, Input, Toggle } from "@/shared/components"; import { cn } from "@/shared/utils/cn"; +import { matchesSearch } from "@/shared/utils/turkishText"; import FusionDefaultsFields from "./FusionDefaultsFields"; import { ROUTING_STRATEGIES, @@ -97,6 +98,7 @@ export default function ComboDefaultsTab() { handoffModel: "", maxMessagesForSummary: 30, stickyRoundRobinLimit: 3, + disableSessionStickiness: false, resetAwareQuotaCacheTtlMs: 0, resetAwareQuotaCacheMaxStaleMs: 0, zeroLatencyOptimizationsEnabled: false, @@ -168,6 +170,10 @@ export default function ComboDefaultsTab() { settingsData.stickyRoundRobinLimit ?? comboData.comboDefaults?.stickyRoundRobinLimit ?? prev.stickyRoundRobinLimit, + disableSessionStickiness: + settingsData.disableSessionStickiness ?? + comboData.comboDefaults?.disableSessionStickiness ?? + prev.disableSessionStickiness, })); if (comboData.providerOverrides) { setProviderOverrides(sanitizeProviderOverrides(comboData.providerOverrides)); @@ -214,10 +220,14 @@ export default function ComboDefaultsTab() { const saveComboDefaults = async () => { setSaving(true); try { - const { stickyRoundRobinLimit, ...comboDefaultsPayload } = comboDefaults; + const { stickyRoundRobinLimit, disableSessionStickiness, ...comboDefaultsPayload } = + comboDefaults; const settingsPatch = { ...toGlobalRoutingPatch(comboDefaults.strategy, stickyRoundRobinLimit), codexSessionAffinityTtlMs, + // #6168: global session-stickiness opt-out — persisted top-level on settings + // (mirrors stickyRoundRobinLimit) so combo.ts resolution reads settings.disableSessionStickiness. + disableSessionStickiness: disableSessionStickiness === true, }; const comboDefaultsRes = await fetch("/api/settings/combo-defaults", { @@ -283,7 +293,7 @@ export default function ComboDefaultsTab() { // Filtered provider list — excludes already-added ones, filtered by search query const filteredProviders = availableProviders.filter( (p) => - !providerOverrides[p.provider] && p.provider.toLowerCase().includes(searchQuery.toLowerCase()) + !providerOverrides[p.provider] && matchesSearch(p.provider, searchQuery) ); const handleDropdownKeyDown = (e: React.KeyboardEvent) => { @@ -712,6 +722,29 @@ export default function ComboDefaultsTab() { } />
+
+
+

+ {translateOrFallback(t, "disableSessionStickiness", "Disable session stickiness")} +

+

+ {translateOrFallback( + t, + "disableSessionStickinessDesc", + "Round-robin and random combos rotate to a different connection on every request instead of pinning a whole conversation to one connection by the first-message hash. Leave off to preserve prompt-cache hits for multi-turn chats. Per-combo overrides take precedence." + )} +

+
+ + setComboDefaults((prev) => ({ + ...prev, + disableSessionStickiness: prev.disableSessionStickiness !== true, + })) + } + /> +
{/* Provider Overrides */} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 98d387f1a8..9cc77a13c3 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2514,7 +2514,8 @@ "queueTimeout": "How long a request can wait in queue before timing out.", "failoverBeforeRetry": "When enabled, any upstream error triggers immediate failover to the next combo target, skipping all retries and fallback URLs.", "maxSetRetries": "Number of times to retry the full target set when every target fails. 0 = no set-level retry.", - "setRetryDelayMs": "Delay between set-level retry attempts, giving transient issues time to resolve." + "setRetryDelayMs": "Delay between set-level retry attempts, giving transient issues time to resolve.", + "disableSessionStickiness": "Rotate to a different connection on every request instead of pinning a whole conversation to one connection by the first-message hash. Overrides the global default. Leave on Inherit to preserve prompt-cache hits for multi-turn chats." }, "templatesTitle": "Quick templates", "templatesDescription": "Apply a starting profile, then adjust models and config.", @@ -2828,7 +2829,10 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000", "compressionOverride": "Compression Override", - "modePack": "Mode Pack" + "modePack": "Mode Pack", + "disableSessionStickiness": "Disable session stickiness", + "sessionStickinessEnabled": "Stickiness on", + "sessionStickinessDisabled": "Stickiness off" }, "costs": { "title": "Costs", @@ -6152,7 +6156,9 @@ "modelLockoutExponentialBackoff": "Exponential Backoff", "modelLockoutExponentialBackoffDescription": "When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "disableSessionStickiness": "Disable session stickiness", + "disableSessionStickinessDesc": "Round-robin and random combos rotate to a different connection on every request instead of pinning a whole conversation to one connection by the first-message hash. Leave off to preserve prompt-cache hits for multi-turn chats. Per-combo overrides take precedence." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index c6aa4bb832..d0c8dd9b45 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -96,6 +96,7 @@ export async function getSettings() { tailscaleEnabled: false, tailscaleUrl: "", stickyRoundRobinLimit: 3, + disableSessionStickiness: false, requestRetry: 3, maxRetryIntervalSec: 30, antigravitySignatureCacheMode: "enabled", diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts index 792d53f60e..568fd7bfcf 100644 --- a/src/shared/validation/schemas/combo.ts +++ b/src/shared/validation/schemas/combo.ts @@ -159,6 +159,12 @@ export const comboRuntimeConfigSchema = z // falls back to the global `settings.stickyRoundRobinLimit` so the existing // knob still controls the default. 0 clamps to 1 (no batching) upstream. stickyRoundRobinLimit: z.coerce.number().int().min(0).max(1000).optional(), + // #6168: opt-out for per-conversation session stickiness. When true, round-robin + // and random/weighted/priority combos rotate freely instead of pinning a whole + // conversation to one connection by the first-message hash. Per-combo `config` + // wins over the global `settings.disableSessionStickiness` fallback. Default false + // preserves the #3825 prompt-cache/504 fix. + disableSessionStickiness: z.boolean().optional(), stickyWeightedLimit: z.coerce.number().int().min(0).max(1000).optional(), healthCheckEnabled: z.boolean().optional(), healthCheckTimeoutMs: z.coerce.number().int().min(100).max(30000).optional(), diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index de513b621e..cb0b63d084 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -183,6 +183,8 @@ export const updateSettingsSchema = z.object({ fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(), wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(), stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(), + // #6168: global session-stickiness opt-out (per-combo config overrides this). + disableSessionStickiness: z.boolean().optional(), requestRetry: z.number().int().min(0).max(10).optional(), maxRetryIntervalSec: z.number().int().min(0).max(300).optional(), maxBodySizeMb: z diff --git a/tests/unit/combo-disable-session-stickiness.test.ts b/tests/unit/combo-disable-session-stickiness.test.ts new file mode 100644 index 0000000000..88bfca2d4b --- /dev/null +++ b/tests/unit/combo-disable-session-stickiness.test.ts @@ -0,0 +1,233 @@ +/** + * tests/unit/combo-disable-session-stickiness.test.ts + * + * #6168 — "disable session stickiness" opt-out toggle. + * + * Two things are covered: + * 1. resolveDisableSessionStickiness(config, settings) — the real production + * resolver used at BOTH call sites in open-sse/services/combo.ts: + * per-combo config.disableSessionStickiness (boolean) + * → global settings.disableSessionStickiness (boolean) + * → default false. + * 2. The GATE behavior — the exact ternary combo.ts uses at both the main + * dispatch loop (combo.ts ~1078) and the round-robin handler (combo.ts ~2404): + * const disable = resolveDisableSessionStickiness(config, settings); + * const result = disable + * ? { targets, messageHash: null, stuck: false } + * : await applySessionStickiness(targets, messages); + * With the flag ON, applySessionStickiness must NOT run (targets left as-is, + * stuck:false, messageHash:null → recordStickyBinding write-back is skipped) + * EVEN THOUGH a healthy sticky binding exists. With the flag OFF/absent the + * conversation still pins to its sticky connection (#3825 preserved). + * + * Saturation is injected via __setStickinessHeadroomFetcherForTests — no network, + * no DB, fully deterministic. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import type { HeadroomSaturation } from "../../open-sse/services/combo/headroomRanking.ts"; +import type { ResolvedComboTarget } from "../../open-sse/services/combo/types.ts"; + +const mod = await import("../../open-sse/services/combo/sessionStickiness.ts"); +const { + applySessionStickiness, + recordStickyBinding, + clearAllStickyBindings, + deriveMessageHash, + resolveDisableSessionStickiness, + __setStickinessHeadroomFetcherForTests, +} = mod; + +function makeTarget(connectionId: string): ResolvedComboTarget { + return { + kind: "model", + stepId: `step-${connectionId}`, + executionKey: `key-${connectionId}`, + modelStr: `gpt-4/${connectionId}`, + provider: "openai", + providerId: null, + connectionId, + weight: 1, + label: null, + }; +} + +function injectSat(sat: HeadroomSaturation | undefined): void { + __setStickinessHeadroomFetcherForTests(async (_id: string) => sat); +} + +/** + * Faithful reproduction of the gate the production call sites use (combo.ts + * ~1078 main dispatch and ~2404 round-robin — the expression is identical at + * both). `applySessionStickiness` is wrapped so we can OBSERVE whether it ran. + */ +async function gatedStickiness( + targets: ResolvedComboTarget[], + messages: Array<{ role?: string; content?: unknown }> | null | undefined, + config: Record | null | undefined, + settings: Record | null | undefined, + observe: { called: number } +) { + const disable = resolveDisableSessionStickiness(config, settings); + if (disable) { + // No-op — applySessionStickiness NOT invoked (recordStickyBinding skipped). + return { targets, messageHash: null as string | null, stuck: false }; + } + observe.called += 1; + return applySessionStickiness(targets, messages); +} + +test.beforeEach(() => { + clearAllStickyBindings(); + injectSat({ util5h: 0.1, util7d: 0.1 }); // healthy — headroom ~0.9 +}); + +test.after(() => { + __setStickinessHeadroomFetcherForTests(null); +}); + +// ─── resolveDisableSessionStickiness (production resolver) ──────────────────── + +test("resolver: default false when neither config nor settings set it", () => { + assert.equal(resolveDisableSessionStickiness({}, {}), false); + assert.equal(resolveDisableSessionStickiness(null, null), false); + assert.equal(resolveDisableSessionStickiness(undefined, undefined), false); +}); + +test("resolver: global settings fallback (AC #3 global on)", () => { + assert.equal( + resolveDisableSessionStickiness({}, { disableSessionStickiness: true }), + true + ); + assert.equal( + resolveDisableSessionStickiness({}, { disableSessionStickiness: false }), + false + ); +}); + +test("resolver: per-combo config wins over global (AC #3 precedence)", () => { + // combo true beats global false + assert.equal( + resolveDisableSessionStickiness( + { disableSessionStickiness: true }, + { disableSessionStickiness: false } + ), + true + ); + // combo false beats global true + assert.equal( + resolveDisableSessionStickiness( + { disableSessionStickiness: false }, + { disableSessionStickiness: true } + ), + false + ); +}); + +test("resolver: non-boolean per-combo value is ignored, falls back to global", () => { + assert.equal( + resolveDisableSessionStickiness( + { disableSessionStickiness: "true" as unknown as boolean }, + { disableSessionStickiness: true } + ), + true + ); +}); + +// ─── Gate behavior — flag ON bypasses stickiness on both paths ──────────────── + +test("flag ON (per-combo): applySessionStickiness is bypassed even with a healthy binding", async () => { + const targets = [makeTarget("conn-A"), makeTarget("conn-B"), makeTarget("conn-C")]; + const messages = [{ role: "user", content: "identical first message" }]; + const hash = deriveMessageHash(messages)!; + recordStickyBinding(hash, "conn-C"); // would normally pin conn-C to index 0 + + const observe = { called: 0 }; + const result = await gatedStickiness( + targets, + messages, + { disableSessionStickiness: true }, + { disableSessionStickiness: false }, + observe + ); + + assert.equal(observe.called, 0, "applySessionStickiness must NOT be called when disabled"); + assert.equal(result.stuck, false, "no stickiness applied"); + assert.equal(result.messageHash, null, "no hash → recordStickyBinding write-back skipped"); + assert.deepEqual( + result.targets.map((t) => t.connectionId), + ["conn-A", "conn-B", "conn-C"], + "targets left untouched (no reordering to the sticky connection)" + ); +}); + +test("flag ON (global settings): identical first message fans out to distinct targets across N requests", async () => { + const targets = [makeTarget("conn-A"), makeTarget("conn-B"), makeTarget("conn-C")]; + const messages = [{ role: "user", content: "same first message every time" }]; + const hash = deriveMessageHash(messages)!; + recordStickyBinding(hash, "conn-B"); + + // Simulate the round-robin start-index rotation the RR handler applies AFTER + // the (now-bypassed) stickiness step: rrStartIndex stays at the rotation + // counter instead of being overridden to the sticky target's index. + const firstOfEach: string[] = []; + for (let i = 0; i < 3; i++) { + const observe = { called: 0 }; + const result = await gatedStickiness( + targets, + messages, + {}, + { disableSessionStickiness: true }, + observe + ); + assert.equal(observe.called, 0); + assert.equal(result.stuck, false); + // Rotation is free to pick index i since stickiness did not force index 0. + firstOfEach.push(result.targets[i % targets.length].connectionId!); + } + assert.deepEqual( + firstOfEach, + ["conn-A", "conn-B", "conn-C"], + "rotation is free — not collapsed onto the sticky connection" + ); +}); + +// ─── Regression: flag OFF/absent preserves #3825 stickiness ─────────────────── + +test("flag OFF/absent: identical first message still pins to the same target (#3825 preserved)", async () => { + const targets = [makeTarget("conn-A"), makeTarget("conn-B"), makeTarget("conn-C")]; + const messages = [{ role: "user", content: "identical first message" }]; + const hash = deriveMessageHash(messages)!; + recordStickyBinding(hash, "conn-C"); + + const observe = { called: 0 }; + const r1 = await gatedStickiness(targets, messages, {}, {}, observe); + const r2 = await gatedStickiness(targets, messages, {}, {}, observe); + + assert.equal(observe.called, 2, "applySessionStickiness runs on the default path"); + assert.ok(r1.stuck, "first request pins to sticky connection"); + assert.ok(r2.stuck, "second request stays pinned"); + assert.equal(r1.targets[0].connectionId, "conn-C"); + assert.equal(r2.targets[0].connectionId, "conn-C"); +}); + +test("flag explicit false (per-combo) also preserves stickiness", async () => { + const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; + const messages = [{ role: "user", content: "pin me" }]; + const hash = deriveMessageHash(messages)!; + recordStickyBinding(hash, "conn-B"); + + const observe = { called: 0 }; + const result = await gatedStickiness( + targets, + messages, + { disableSessionStickiness: false }, + { disableSessionStickiness: true }, // per-combo false must win + observe + ); + + assert.equal(observe.called, 1, "per-combo false → stickiness still runs"); + assert.ok(result.stuck); + assert.equal(result.targets[0].connectionId, "conn-B"); +});