/** * tests/unit/combo-session-stickiness.test.ts * * Unit tests for open-sse/services/combo/sessionStickiness.ts * * Design: saturation is injected via __setStickinessHeadroomFetcherForTests — * no network, no DB, fully deterministic. * * Coverage: * - deriveMessageHash: stable key derivation from first user message * - applySessionStickiness: same hash → same connection while healthy * - applySessionStickiness: saturated connection → rebind (clear + fall through) * - applySessionStickiness: no user message → normal ordering, no crash * - applySessionStickiness: different hashes → can map to different connections * - applySessionStickiness: saturation fetch error → fail-open * - recordStickyBinding / clearStickyBinding lifecycle */ import test from "node:test"; import assert from "node:assert/strict"; import type { HeadroomSaturation } from "../../open-sse/services/combo/headroomRanking.ts"; const mod = await import("../../open-sse/services/combo/sessionStickiness.ts"); const { deriveMessageHash, applySessionStickiness, recordStickyBinding, clearStickyBinding, clearAllStickyBindings, __setStickinessHeadroomFetcherForTests, STICKINESS_HEADROOM_THRESHOLD, } = mod; // ─── helpers ───────────────────────────────────────────────────────────────── function makeTarget(connectionId: string): import("../../open-sse/services/combo/types.ts").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); } // ─── Test lifecycle ─────────────────────────────────────────────────────────── test.beforeEach(() => { clearAllStickyBindings(); // Default: healthy connection (headroom = 0.5) injectSat({ util5h: 0.3, util7d: 0.2 }); }); test.after(() => { __setStickinessHeadroomFetcherForTests(null); }); // ─── deriveMessageHash ─────────────────────────────────────────────────────── test("deriveMessageHash: returns 16-char hex for a plain user message", () => { const hash = deriveMessageHash([{ role: "user", content: "Hello world" }]); assert.ok(hash !== null, "hash should not be null"); assert.match(hash!, /^[a-f0-9]{16}$/); }); test("deriveMessageHash: same content → same hash (stable)", () => { const msgs = [{ role: "user", content: "How are you?" }]; assert.equal(deriveMessageHash(msgs), deriveMessageHash(msgs)); }); test("deriveMessageHash: different first messages → different hashes", () => { const h1 = deriveMessageHash([{ role: "user", content: "AAA" }]); const h2 = deriveMessageHash([{ role: "user", content: "BBB" }]); assert.notEqual(h1, h2); }); test("deriveMessageHash: skips assistant messages, uses first user", () => { const msgs = [ { role: "assistant", content: "Hi" }, { role: "user", content: "My real first message" }, ]; const direct = [{ role: "user", content: "My real first message" }]; assert.equal(deriveMessageHash(msgs), deriveMessageHash(direct)); }); test("deriveMessageHash: multi-part content array is hashed on text parts", () => { const msgs = [ { role: "user", content: [ { type: "text", text: "Hello" }, { type: "image_url", url: "http://example.com/img.png" }, ], }, ]; const hash = deriveMessageHash(msgs); assert.ok(hash !== null); assert.match(hash!, /^[a-f0-9]{16}$/); // Stability assert.equal(deriveMessageHash(msgs), hash); }); test("deriveMessageHash: null/empty → null (fail-open)", () => { assert.equal(deriveMessageHash(null), null); assert.equal(deriveMessageHash([]), null); assert.equal(deriveMessageHash(undefined), null); // No user message assert.equal(deriveMessageHash([{ role: "assistant", content: "Hi" }]), null); }); // ─── applySessionStickiness — main scenarios ────────────────────────────────── test("same message hash → same connection in repeated calls while healthy", async () => { const targets = [makeTarget("conn-A"), makeTarget("conn-B"), makeTarget("conn-C")]; const messages = [{ role: "user", content: "First turn of conversation" }]; const hash = deriveMessageHash(messages)!; assert.ok(hash, "hash must be derivable"); // Simulate first successful request: record sticky to conn-B recordStickyBinding(hash, "conn-B"); // Call 1 const r1 = await applySessionStickiness(targets, messages); assert.ok(r1.stuck, "should be stuck on first call"); assert.equal(r1.targets[0].connectionId, "conn-B", "conn-B should be first"); // Call 2 (same conversation) const r2 = await applySessionStickiness(targets, messages); assert.ok(r2.stuck, "should remain stuck on second call"); assert.equal(r2.targets[0].connectionId, "conn-B"); }); test("saturated connection → rebind (clear sticky, return normal ordering)", async () => { injectSat({ util5h: 0.9, util7d: 0.9 }); // headroom = 0.1, below threshold const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; const messages = [{ role: "user", content: "Saturation test" }]; const hash = deriveMessageHash(messages)!; recordStickyBinding(hash, "conn-A"); const result = await applySessionStickiness(targets, messages); assert.equal(result.stuck, false, "should NOT be stuck when saturated"); // Normal order preserved (conn-A stays at index 0 — just not "stuck") assert.equal(result.targets[0].connectionId, "conn-A"); // Binding must have been cleared — subsequent call also returns not stuck // (even with healthy fetcher since the binding is gone) injectSat({ util5h: 0.1, util7d: 0.1 }); // now healthy const result2 = await applySessionStickiness(targets, messages); assert.equal(result2.stuck, false, "binding was cleared, no stickiness on follow-up"); }); test("no user message in body → normal ordering, no crash", async () => { const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; const r1 = await applySessionStickiness(targets, null); assert.equal(r1.stuck, false); assert.deepEqual( r1.targets.map((t) => t.connectionId), ["conn-A", "conn-B"] ); const r2 = await applySessionStickiness(targets, []); assert.equal(r2.stuck, false); const r3 = await applySessionStickiness(targets, undefined); assert.equal(r3.stuck, false); }); test("different message hashes can map to different connections", async () => { injectSat({ util5h: 0.0, util7d: 0.0 }); // full headroom const targets = [makeTarget("conn-X"), makeTarget("conn-Y")]; const msgs1 = [{ role: "user", content: "Conversation Alpha" }]; const msgs2 = [{ role: "user", content: "Conversation Beta" }]; const hash1 = deriveMessageHash(msgs1)!; const hash2 = deriveMessageHash(msgs2)!; assert.notEqual(hash1, hash2, "hashes must differ"); recordStickyBinding(hash1, "conn-X"); recordStickyBinding(hash2, "conn-Y"); const r1 = await applySessionStickiness(targets, msgs1); const r2 = await applySessionStickiness(targets, msgs2); assert.ok(r1.stuck); assert.ok(r2.stuck); assert.equal(r1.targets[0].connectionId, "conn-X"); assert.equal(r2.targets[0].connectionId, "conn-Y"); }); test("saturation fetch error → fail-open (original order, no crash)", async () => { __setStickinessHeadroomFetcherForTests(async (_id: string) => { throw new Error("network failure"); }); const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; const messages = [{ role: "user", content: "Error path" }]; const hash = deriveMessageHash(messages)!; recordStickyBinding(hash, "conn-A"); const result = await applySessionStickiness(targets, messages); assert.equal(result.stuck, false, "must not crash on fetcher error"); assert.deepEqual( result.targets.map((t) => t.connectionId), ["conn-A", "conn-B"], "original order preserved" ); }); test("single target list → no-op, no sticky lookup performed", async () => { const targets = [makeTarget("conn-solo")]; const messages = [{ role: "user", content: "Only one target" }]; const hash = deriveMessageHash(messages)!; recordStickyBinding(hash, "conn-solo"); const result = await applySessionStickiness(targets, messages); assert.equal(result.stuck, false, "no stickiness applied for single target"); assert.deepEqual(result.targets, targets); }); test("STICKINESS_HEADROOM_THRESHOLD: connection below threshold is NOT reused", async () => { // headroom = 1 − 0.9 = 0.1, clearly below threshold 0.15 injectSat({ util5h: 0.9, util7d: 0.0 }); const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; const messages = [{ role: "user", content: "Below threshold" }]; const hash = deriveMessageHash(messages)!; recordStickyBinding(hash, "conn-A"); const result = await applySessionStickiness(targets, messages); assert.equal(result.stuck, false, "connection below threshold must not be reused"); }); test("connection just above threshold IS reused", async () => { // headroom = 1 − 0.84 = 0.16 > 0.15 injectSat({ util5h: 0.84, util7d: 0.0 }); const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; const messages = [{ role: "user", content: "Above threshold" }]; const hash = deriveMessageHash(messages)!; recordStickyBinding(hash, "conn-A"); const result = await applySessionStickiness(targets, messages); assert.ok(result.stuck, "connection just above threshold should be reused"); assert.equal(result.targets[0].connectionId, "conn-A"); }); test("sticky connection no longer in target list → clear binding, normal order", async () => { const targets = [makeTarget("conn-X"), makeTarget("conn-Y")]; const messages = [{ role: "user", content: "Removed connection" }]; const hash = deriveMessageHash(messages)!; // Sticky to conn-GONE which is NOT in targets recordStickyBinding(hash, "conn-GONE"); const result = await applySessionStickiness(targets, messages); assert.equal(result.stuck, false, "should not be stuck when connection is gone"); assert.deepEqual( result.targets.map((t) => t.connectionId), ["conn-X", "conn-Y"] ); }); // ─── recordStickyBinding / clearStickyBinding lifecycle ────────────────────── test("recordStickyBinding: creates a new binding and stickiness is applied", async () => { const targets = [makeTarget("conn-1"), makeTarget("conn-2")]; const messages = [{ role: "user", content: "Lifecycle test" }]; const hash = deriveMessageHash(messages)!; recordStickyBinding(hash, "conn-1"); const r = await applySessionStickiness(targets, messages); assert.ok(r.stuck); assert.equal(r.targets[0].connectionId, "conn-1"); }); test("clearStickyBinding: removes the binding so next call is normal ordering", async () => { const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; const messages = [{ role: "user", content: "Clear test" }]; const hash = deriveMessageHash(messages)!; recordStickyBinding(hash, "conn-B"); clearStickyBinding(hash); const r = await applySessionStickiness(targets, messages); assert.equal(r.stuck, false, "binding was cleared, no stickiness"); }); test("recordStickyBinding: updating to a new connectionId rebinds correctly", async () => { const targets = [makeTarget("conn-OLD"), makeTarget("conn-NEW")]; const messages = [{ role: "user", content: "Rebind test" }]; const hash = deriveMessageHash(messages)!; recordStickyBinding(hash, "conn-OLD"); // Rebind to conn-NEW recordStickyBinding(hash, "conn-NEW"); const r = await applySessionStickiness(targets, messages); assert.ok(r.stuck); assert.equal(r.targets[0].connectionId, "conn-NEW"); }); test("messageHash is returned in result even when no binding exists", async () => { const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; const messages = [{ role: "user", content: "No binding yet" }]; const r = await applySessionStickiness(targets, messages); assert.equal(r.stuck, false); assert.ok(r.messageHash !== null, "hash should be derivable and returned"); assert.match(r.messageHash!, /^[a-f0-9]{16}$/); });