Files
diegosouzapw--omniroute/tests/unit/combo-session-stickiness.test.ts
2026-07-13 13:39:12 +08:00

323 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}$/);
});