feat(combo): add option to disable session stickiness (#6168) (#6252)

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)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-05 07:47:34 -03:00
committed by GitHub
parent cefbcfb278
commit 1044821bda
10 changed files with 385 additions and 12 deletions

View File

@@ -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

View File

@@ -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<string, unknown> | null | undefined,
settings as Record<string, unknown> | 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<string, unknown> | null | undefined,
settings as Record<string, unknown> | 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(

View File

@@ -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<string, unknown> | null | undefined,
settings: Record<string, unknown> | 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 {

View File

@@ -3972,6 +3972,54 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
<option value="execute">Execute nested combos as targets</option>
</select>
</div>
{/* #6168: per-combo session-stickiness override (tri-state so it can
force ON or OFF regardless of the global default; blank = inherit). */}
<div>
<FieldLabelWithHelp
label={getI18nOrFallback(
t,
"disableSessionStickiness",
"Disable session stickiness"
)}
help={getI18nOrFallback(
t,
"advancedHelp.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."
)}
showHelp={!isExpertMode}
/>
<select
value={
config.disableSessionStickiness === true
? "disabled"
: config.disableSessionStickiness === false
? "enabled"
: "inherit"
}
onChange={(e) =>
setConfig({
...config,
disableSessionStickiness:
e.target.value === "disabled"
? true
: e.target.value === "enabled"
? false
: undefined,
})
}
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-surface-1 focus:border-primary focus:outline-none"
>
<option value="inherit">
{getI18nOrFallback(t, "stickyLimitInherit", "inherit")}
</option>
<option value="enabled">
{getI18nOrFallback(t, "sessionStickinessEnabled", "Stickiness on")}
</option>
<option value="disabled">
{getI18nOrFallback(t, "sessionStickinessDisabled", "Stickiness off")}
</option>
</select>
</div>
</div>
{strategy === "context-relay" && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 pt-2 border-t border-black/5 dark:border-white/5">

View File

@@ -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() {
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">
{translateOrFallback(t, "disableSessionStickiness", "Disable session stickiness")}
</p>
<p className="text-xs text-text-muted">
{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."
)}
</p>
</div>
<Toggle
checked={comboDefaults.disableSessionStickiness === true}
onChange={() =>
setComboDefaults((prev) => ({
...prev,
disableSessionStickiness: prev.disableSessionStickiness !== true,
}))
}
/>
</div>
</div>
{/* Provider Overrides */}

View File

@@ -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",

View File

@@ -96,6 +96,7 @@ export async function getSettings() {
tailscaleEnabled: false,
tailscaleUrl: "",
stickyRoundRobinLimit: 3,
disableSessionStickiness: false,
requestRetry: 3,
maxRetryIntervalSec: 30,
antigravitySignatureCacheMode: "enabled",

View File

@@ -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(),

View File

@@ -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

View File

@@ -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<string, unknown> | null | undefined,
settings: Record<string, unknown> | 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");
});