Files
agents-marketplace/tests/unit/shared-chat-message.test.ts
david 9a4f9f0699 fix(gateway,supplier): demo sessions stick to their supplier; free pinned suppliers
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>
2026-07-09 02:29:10 +02:00

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);
});
});