245 lines
9.3 KiB
TypeScript
245 lines
9.3 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
capMemoryExtractionText,
|
|
truncateChatLogText,
|
|
cloneBoundedChatLogPayload,
|
|
truncateForLog,
|
|
MEMORY_EXTRACTION_TEXT_LIMIT,
|
|
} from "../../open-sse/handlers/chatCore/logTruncation.ts";
|
|
import {
|
|
getChatLogTextLimit,
|
|
getChatLogArrayTailItems,
|
|
getChatLogMaxDepth,
|
|
getChatLogMaxObjectKeys,
|
|
} from "../../src/lib/logEnv.ts";
|
|
|
|
test("MEMORY_EXTRACTION_TEXT_LIMIT is the documented 64KB constant", () => {
|
|
assert.equal(MEMORY_EXTRACTION_TEXT_LIMIT, 64 * 1024);
|
|
});
|
|
|
|
test("capMemoryExtractionText returns short strings unchanged", () => {
|
|
assert.equal(capMemoryExtractionText("hello"), "hello");
|
|
// exactly at the limit is not truncated (<= limit)
|
|
const exact = "x".repeat(MEMORY_EXTRACTION_TEXT_LIMIT);
|
|
assert.equal(capMemoryExtractionText(exact), exact);
|
|
});
|
|
|
|
test("capMemoryExtractionText keeps the trailing window of over-limit strings", () => {
|
|
const long = "a".repeat(MEMORY_EXTRACTION_TEXT_LIMIT) + "TAIL";
|
|
const capped = capMemoryExtractionText(long);
|
|
assert.equal(capped.length, MEMORY_EXTRACTION_TEXT_LIMIT);
|
|
// slice(-LIMIT) keeps the end, so the appended TAIL survives.
|
|
assert.ok(capped.endsWith("TAIL"));
|
|
});
|
|
|
|
test("truncateChatLogText passes through strings up to the configured limit", () => {
|
|
const limit = getChatLogTextLimit();
|
|
const under = "y".repeat(limit);
|
|
assert.equal(truncateChatLogText(under), under);
|
|
});
|
|
|
|
test("truncateChatLogText builds head + marker + tail for over-limit strings", () => {
|
|
const limit = getChatLogTextLimit();
|
|
const head = "H".repeat(Math.floor(limit / 2));
|
|
const tail = "T".repeat(Math.ceil(limit / 2));
|
|
// make the total strictly larger than the limit by inserting a middle chunk
|
|
const middle = "M".repeat(500);
|
|
const value = head + middle + tail;
|
|
const out = truncateChatLogText(value);
|
|
const expectedHead = value.slice(0, Math.floor(limit / 2));
|
|
const expectedTail = value.slice(-Math.ceil(limit / 2));
|
|
assert.equal(
|
|
out,
|
|
`${expectedHead}\n[...truncated ${value.length - limit} chars...]\n${expectedTail}`
|
|
);
|
|
// sanity: the marker reports exactly the number of dropped chars
|
|
assert.ok(out.includes(`[...truncated ${value.length - limit} chars...]`));
|
|
});
|
|
|
|
test("cloneBoundedChatLogPayload returns null/undefined/primitives as-is", () => {
|
|
assert.equal(cloneBoundedChatLogPayload(null), null);
|
|
assert.equal(cloneBoundedChatLogPayload(undefined), undefined);
|
|
assert.equal(cloneBoundedChatLogPayload(42), 42);
|
|
assert.equal(cloneBoundedChatLogPayload(true), true);
|
|
});
|
|
|
|
test("cloneBoundedChatLogPayload truncates long string leaves via truncateChatLogText", () => {
|
|
const limit = getChatLogTextLimit();
|
|
const big = "z".repeat(limit + 1000);
|
|
const out = cloneBoundedChatLogPayload(big) as string;
|
|
assert.equal(out, truncateChatLogText(big));
|
|
assert.ok(out.includes("[...truncated"));
|
|
});
|
|
|
|
test("cloneBoundedChatLogPayload tail-truncates long arrays with a marker entry", () => {
|
|
const maxTail = getChatLogArrayTailItems();
|
|
const n = maxTail + 100;
|
|
const cloned = cloneBoundedChatLogPayload(new Array(n).fill("a")) as unknown[];
|
|
// marker prepended + the retained tail items
|
|
assert.equal(cloned.length, maxTail + 1);
|
|
const marker = cloned[0] as Record<string, unknown>;
|
|
assert.equal(marker._omniroute_truncated_array, true);
|
|
assert.equal(marker.originalLength, n);
|
|
assert.equal(marker.retainedTailItems, maxTail);
|
|
});
|
|
|
|
test("cloneBoundedChatLogPayload leaves short arrays unmarked", () => {
|
|
const cloned = cloneBoundedChatLogPayload(["a", "b", "c"]) as unknown[];
|
|
assert.deepEqual(cloned, ["a", "b", "c"]);
|
|
});
|
|
|
|
test("cloneBoundedChatLogPayload caps object keys and records the dropped count", () => {
|
|
const maxKeys = getChatLogMaxObjectKeys();
|
|
const obj: Record<string, number> = {};
|
|
for (let i = 0; i < maxKeys + 5; i += 1) obj[`k${i}`] = i;
|
|
const cloned = cloneBoundedChatLogPayload(obj) as Record<string, unknown>;
|
|
// first maxKeys keys retained + the synthetic _omniroute_truncated_keys field
|
|
assert.equal(cloned._omniroute_truncated_keys, 5);
|
|
assert.equal(cloned.k0, 0);
|
|
assert.equal(cloned[`k${maxKeys - 1}`], maxKeys - 1);
|
|
// a key beyond the cap is dropped
|
|
assert.equal(cloned[`k${maxKeys}`], undefined);
|
|
});
|
|
|
|
test("cloneBoundedChatLogPayload stops at max depth with a [MaxDepth] sentinel", () => {
|
|
const depth = getChatLogMaxDepth();
|
|
// build nesting that goes one level past the cap
|
|
let leaf: Record<string, unknown> = { deep: "value" };
|
|
for (let i = 0; i < depth + 1; i += 1) leaf = { nested: leaf };
|
|
const cloned = cloneBoundedChatLogPayload(leaf) as Record<string, unknown>;
|
|
// walk down `depth` levels; the level at >= maxDepth becomes the sentinel string
|
|
let cursor: unknown = cloned;
|
|
for (let i = 0; i < depth; i += 1) {
|
|
cursor = (cursor as Record<string, unknown>).nested;
|
|
}
|
|
assert.equal(cursor, "[MaxDepth]");
|
|
});
|
|
|
|
test("truncateForLog returns null/undefined and non-object primitives unchanged", () => {
|
|
assert.equal(truncateForLog(null), null);
|
|
assert.equal(truncateForLog(undefined), undefined);
|
|
assert.equal(truncateForLog("hi" as unknown), "hi" as unknown);
|
|
});
|
|
|
|
test("truncateForLog passes small objects through untouched", () => {
|
|
const small = { model: "gpt-4o", messages: [{ role: "user", content: "hi" }] };
|
|
assert.equal(truncateForLog(small), small);
|
|
});
|
|
|
|
test("truncateForLog summarizes oversized payloads instead of cloning", () => {
|
|
const huge = {
|
|
model: "gpt-4o",
|
|
provider: "openai",
|
|
stream: true,
|
|
// distinct object references so estimateSizeFast (WeakSet-dedup) counts each one
|
|
messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(64) })),
|
|
contents: [{ a: 1 }],
|
|
};
|
|
const summary = truncateForLog(huge) as Record<string, unknown>;
|
|
assert.equal(summary._truncated, true);
|
|
assert.equal(typeof summary._originalBytes, "number");
|
|
assert.ok((summary._originalBytes as number) > 8 * 1024);
|
|
assert.equal(summary.model, "gpt-4o");
|
|
assert.equal(summary.provider, "openai");
|
|
assert.equal(summary.messageCount, 50000);
|
|
assert.equal(summary.contentCount, 1);
|
|
assert.equal(summary.stream, true);
|
|
// the original (huge) is NOT returned — it is a fresh summary object
|
|
assert.notEqual(summary, huge);
|
|
});
|
|
|
|
test("truncateForLog keeps a bounded `tools` field alive when the request is summarized", () => {
|
|
// A request whose message history alone blows well past the 8KB summary
|
|
// threshold, but which also carries `tools` — a field that used to be
|
|
// silently dropped once the payload got summarized.
|
|
const tools = [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Get the current weather for a location",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { location: { type: "string" } },
|
|
required: ["location"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "search_web",
|
|
description: "Search the web for a query",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { query: { type: "string" } },
|
|
required: ["query"],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
const huge = {
|
|
model: "gpt-4o",
|
|
provider: "openai",
|
|
stream: true,
|
|
messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(64) })),
|
|
tools,
|
|
};
|
|
|
|
const summary = truncateForLog(huge) as Record<string, unknown>;
|
|
|
|
// it is still truncated/summarized — the fix must not change this
|
|
assert.equal(summary._truncated, true);
|
|
assert.equal(summary.messageCount, 50000);
|
|
|
|
// ...but `tools` now survives, bounded via cloneBoundedChatLogPayload
|
|
assert.ok(summary.tools, "expected the summary to retain a `tools` field");
|
|
const clonedTools = summary.tools as Array<Record<string, unknown>>;
|
|
assert.equal(clonedTools.length, tools.length);
|
|
assert.equal(
|
|
(clonedTools[0].function as Record<string, unknown>).name,
|
|
"get_weather"
|
|
);
|
|
assert.equal(
|
|
(clonedTools[1].function as Record<string, unknown>).name,
|
|
"search_web"
|
|
);
|
|
});
|
|
|
|
test("truncateForLog bounds an oversized `tools` array to the configured tail-item cap", () => {
|
|
const maxTailItems = getChatLogArrayTailItems();
|
|
const manyTools = Array.from({ length: maxTailItems + 50 }, (_, i) => ({
|
|
type: "function",
|
|
function: { name: `tool_${i}`, description: "d", parameters: {} },
|
|
}));
|
|
const huge = {
|
|
model: "gpt-4o",
|
|
messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(64) })),
|
|
tools: manyTools,
|
|
};
|
|
|
|
const summary = truncateForLog(huge) as Record<string, unknown>;
|
|
assert.equal(summary._truncated, true);
|
|
|
|
const clonedTools = summary.tools as unknown[];
|
|
// marker entry + retained tail items — never the full (unbounded) list
|
|
assert.equal(clonedTools.length, maxTailItems + 1);
|
|
const marker = clonedTools[0] as Record<string, unknown>;
|
|
assert.equal(marker._omniroute_truncated_array, true);
|
|
assert.equal(marker.originalLength, manyTools.length);
|
|
assert.equal(marker.retainedTailItems, maxTailItems);
|
|
});
|
|
|
|
test("truncateForLog leaves small requests with `tools` unchanged (no regression)", () => {
|
|
const small = {
|
|
model: "gpt-4o",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
tools: [{ type: "function", function: { name: "get_weather", parameters: {} } }],
|
|
};
|
|
const result = truncateForLog(small);
|
|
// untouched — same reference, not a summary or a clone
|
|
assert.equal(result, small);
|
|
});
|