The demo endpoint lost its session on nearly every second message: affinity
required clients to re-echo the assistant reply byte-identically, so clients
that strip <think> blocks or reshape whitespace (Open WebUI, litellm) missed
every turn, opened a fresh escrow session on the next free single-slot
supplier, and left the old one pinned. Open WebUI's background title/tag-gen
requests each pinned another supplier — one message could exhaust all three
kimi replicas ("no available chat supplier").
- affinity: lenient assistant-content matching (think-strip + whitespace
collapse); user/system/tool messages and tool_calls stay byte-strict;
strict prefixes outrank lenient ones. The mirror is never rewritten with
client text, so receipt hashes stay valid.
- demoChat: trim trailing assistant/system messages (regenerate semantics)
instead of 400 "the last message must have role user or tool".
- supplier: roll the transcript back when the LLM call fails so retries
don't duplicate the delta (ChatSessionStore.truncateTranscript).
- demoChat: LRU-evict a demo session when no supplier is available (supplier
/v1/chat/end frees the slot, sweeper Accepts the escrow) and retry the
open with selectCandidates ignoreStatusFor to bypass the ~20s-stale
indexer status poll. Max 2 evictions per request.
- sweeper: idle janitor closes demo sessions unused for DEMO_SESSION_IDLE_MS
(default 3 min, below the supplier's 5 min CHAT_IDLE_TIMEOUT_MS) so
abandoned one-off sessions release their suppliers quickly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
113 lines
5.1 KiB
TypeScript
113 lines
5.1 KiB
TypeScript
/**
|
|
* shared-chat-message.test.ts — normalizeChatMessage / chatMessagesEquivalent.
|
|
*
|
|
* The supplier transcript and the gateway mirror must canonicalize to the SAME
|
|
* string (receipt response_hash = sha256(canonicalize(transcript))), so the
|
|
* normalizer's field-presence behavior is load-bearing: absent optional fields
|
|
* must be truly absent, content is never null, vendor extras are stripped.
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "../../packages/shared/src/tx/chatMessage.js";
|
|
import { canonicalize } from "../../packages/shared/src/cbor/canonical.js";
|
|
|
|
const TOOL_CALL = { id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } };
|
|
|
|
describe("normalizeChatMessage", () => {
|
|
it("passes through plain user/system/assistant messages", () => {
|
|
expect(normalizeChatMessage({ role: "user", content: "hi" })).toEqual({ role: "user", content: "hi" });
|
|
expect(normalizeChatMessage({ role: "system", content: "be nice" })).toEqual({ role: "system", content: "be nice" });
|
|
});
|
|
|
|
it("normalizes null/undefined content to empty string", () => {
|
|
expect(normalizeChatMessage({ role: "assistant", content: null, tool_calls: [TOOL_CALL] })).toEqual({
|
|
role: "assistant",
|
|
content: "",
|
|
tool_calls: [TOOL_CALL],
|
|
});
|
|
});
|
|
|
|
it("keeps optional fields ABSENT when not provided (hash identity)", () => {
|
|
const msg = normalizeChatMessage({ role: "assistant", content: "x" })!;
|
|
expect("tool_calls" in msg).toBe(false);
|
|
expect("tool_call_id" in msg).toBe(false);
|
|
});
|
|
|
|
it("strips vendor extras from tool calls", () => {
|
|
const msg = normalizeChatMessage({
|
|
role: "assistant",
|
|
content: "",
|
|
tool_calls: [{ ...TOOL_CALL, extra_vendor_field: 42, function: { ...TOOL_CALL.function, parsed: {} } }],
|
|
})!;
|
|
expect(msg.tool_calls).toEqual([TOOL_CALL]);
|
|
});
|
|
|
|
it("requires tool role to carry tool_call_id", () => {
|
|
expect(normalizeChatMessage({ role: "tool", content: "result" })).toBeNull();
|
|
expect(normalizeChatMessage({ role: "tool", content: "result", tool_call_id: "call_1" })).toEqual({
|
|
role: "tool",
|
|
content: "result",
|
|
tool_call_id: "call_1",
|
|
});
|
|
});
|
|
|
|
it("rejects tool_calls on non-assistant roles and malformed shapes", () => {
|
|
expect(normalizeChatMessage({ role: "user", content: "x", tool_calls: [TOOL_CALL] })).toBeNull();
|
|
expect(normalizeChatMessage({ role: "assistant", content: "", tool_calls: [{ id: "", type: "function", function: { name: "f", arguments: "" } }] })).toBeNull();
|
|
expect(normalizeChatMessage({ role: "bogus", content: "x" })).toBeNull();
|
|
expect(normalizeChatMessage("nope")).toBeNull();
|
|
expect(normalizeChatMessage({ role: "user", content: 42 })).toBeNull();
|
|
});
|
|
|
|
it("canonicalizes identically regardless of input key order (mirror vs transcript)", () => {
|
|
const a = normalizeChatMessage({ role: "assistant", content: "", tool_calls: [TOOL_CALL] })!;
|
|
const b = normalizeChatMessage({
|
|
tool_calls: [{ function: { arguments: "{}", name: "get_time" }, type: "function", id: "call_1" }],
|
|
content: null,
|
|
role: "assistant",
|
|
})!;
|
|
expect(canonicalize([a])).toBe(canonicalize([b]));
|
|
});
|
|
});
|
|
|
|
describe("chatMessagesEquivalent", () => {
|
|
it("matches normalized equals and rejects differences", () => {
|
|
const a = normalizeChatMessage({ role: "assistant", content: "", tool_calls: [TOOL_CALL] })!;
|
|
const b = normalizeChatMessage({ role: "assistant", content: null, tool_calls: [TOOL_CALL] })!;
|
|
expect(chatMessagesEquivalent(a, b)).toBe(true);
|
|
|
|
const c = normalizeChatMessage({ role: "assistant", content: "hi" })!;
|
|
expect(chatMessagesEquivalent(a, c)).toBe(false);
|
|
const d = normalizeChatMessage({
|
|
role: "assistant",
|
|
content: "",
|
|
tool_calls: [{ ...TOOL_CALL, function: { name: "get_time", arguments: '{"tz":"utc"}' } }],
|
|
})!;
|
|
expect(chatMessagesEquivalent(a, d)).toBe(false);
|
|
});
|
|
|
|
it("compares tool results by tool_call_id", () => {
|
|
const a = normalizeChatMessage({ role: "tool", content: "3pm", tool_call_id: "call_1" })!;
|
|
const b = normalizeChatMessage({ role: "tool", content: "3pm", tool_call_id: "call_1" })!;
|
|
const c = normalizeChatMessage({ role: "tool", content: "3pm", tool_call_id: "call_2" })!;
|
|
expect(chatMessagesEquivalent(a, b)).toBe(true);
|
|
expect(chatMessagesEquivalent(a, c)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("toolCallsEqual", () => {
|
|
const CALL = { id: "call_1", type: "function" as const, function: { name: "get_time", arguments: "{}" } };
|
|
|
|
it("treats missing and empty lists as equal (exported for the affinity matcher)", () => {
|
|
expect(toolCallsEqual(undefined, undefined)).toBe(true);
|
|
expect(toolCallsEqual(undefined, [])).toBe(true);
|
|
expect(toolCallsEqual([CALL], [{ ...CALL }])).toBe(true);
|
|
});
|
|
|
|
it("rejects id/name/arguments differences and length mismatches", () => {
|
|
expect(toolCallsEqual([CALL], [])).toBe(false);
|
|
expect(toolCallsEqual([CALL], [{ ...CALL, id: "call_2" }])).toBe(false);
|
|
expect(toolCallsEqual([CALL], [{ ...CALL, function: { name: "get_time", arguments: '{"tz":"utc"}' } }])).toBe(false);
|
|
});
|
|
});
|