项目文件夹

文件
2026-07-13 13:39:12 +08:00

323 行
12 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* 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}$/);
});