Files
OmniRoute/tests/unit/estimateSizeFast.test.ts
Markus Hartung d7db2d1a56 feat(logging): make the chat-log truncation limit configurable, bumped default 128x
The 8KB cap on logged request/response bodies (open-sse/handlers/chatCore/
logTruncation.ts::truncateForLog()) was hardcoded — trivially exceeded by
any real multi-turn agentic conversation, meaning the dashboard's "Full
Conversation" panel could only ever show a placeholder instead of the
actual messages for nearly every logged row of any conversation with real
substance.

- Added CHAT_LOG_MAX_BODY_KB env var (src/lib/logEnv.ts::
  getChatLogMaxBodyBytes()), default 1024 KB (1MB) — a 128x bump from the
  old hardcoded 8KB — following the same configurable-limit pattern as the
  sibling CHAT_LOG_TEXT_LIMIT/CHAT_LOG_ARRAY_TAIL_ITEMS/etc. vars.
- Documented in .env.example and docs/reference/ENVIRONMENT.md (both
  required — tests/unit/issue-7793-env-doc-sync-repro.test.ts and
  check-env-doc-sync.test.ts enforce this pairing).

Found and fixed a real bug while wiring this up: estimateSize.ts's
estimateSizeFast() had its own hardcoded 256KB early-exit optimization
("stop walking once bytes clearly exceeds the caller's threshold"), so it
could never report a size above ~256KB regardless of the object's true
size — meaning any caller threshold configured above 256KB (like the new
1MB default) was silently unreachable; every payload would look "under
threshold" and truncation would never fire, letting arbitrarily large
bodies through unbounded (the opposite of intended, and a real memory-
protection regression). Fixed by giving estimateSizeFast() a parameterized
earlyExitAt (default unchanged at 262144, so isSmallEnoughForSemanticCache's
existing behavior is untouched), with truncateForLog() now passing its own
configured getChatLogMaxBodyBytes() value through.

Also adds a "Conversation" field to the request detail panel's metadata
grid (last, after "Combo"), showing the request's conversation id
(sessionTag) for quick reference/copy.

Test plan:
- New TDD tests for both fixes (Responses API messageCount capture — from
  the previous commit — and the estimateSizeFast earlyExitAt parameter),
  confirmed failing before each fix and passing after
- Bumped the two truncateForLog test fixtures that were sized against the
  old 8KB threshold so they still genuinely exceed the new ~1MB default
- npm run typecheck:core / npm run lint / npm run check:file-size — clean
- npm run test:unit — 27132 tests, same 4 pre-existing/unrelated failures
  as the last confirmed-clean run (no new regressions) — including the two
  env/doc-sync contract tests that initially caught the missing
  CHAT_LOG_MAX_BODY_KB documentation, now fixed
- npm run test:vitest — 291/291 passed
- Rebuilt and redeployed to omniroute-dev
2026-08-05 11:14:12 +02:00

136 lines
5.0 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
const { estimateSizeFast, isSmallEnoughForSemanticCache } =
await import("../../open-sse/utils/estimateSize.ts");
test("estimateSizeFast returns 0 for null/undefined", () => {
assert.equal(estimateSizeFast(null), 0);
assert.equal(estimateSizeFast(undefined), 0);
});
test("estimateSizeFast counts string lengths", () => {
assert.equal(estimateSizeFast("hello"), 5);
assert.equal(estimateSizeFast(""), 0);
});
test("estimateSizeFast counts numbers as 8 bytes", () => {
assert.equal(estimateSizeFast(42), 8);
assert.equal(estimateSizeFast(0), 8);
assert.equal(estimateSizeFast(3.14), 8);
});
test("estimateSizeFast counts booleans as 4 bytes", () => {
assert.equal(estimateSizeFast(true), 4);
assert.equal(estimateSizeFast(false), 4);
});
test("estimateSizeFast walks arrays recursively", () => {
const arr = ["abc", "de", 42];
assert.equal(estimateSizeFast(arr), 3 + 2 + 8); // 13
});
test("estimateSizeFast walks objects recursively", () => {
const obj = { a: "hello", b: 42 };
assert.equal(estimateSizeFast(obj), 5 + 8); // 13
});
test("estimateSizeFast walks nested structures", () => {
const nested = { messages: [{ role: "user", content: "hi" }] };
// role=4, content=2
assert.equal(estimateSizeFast(nested), 4 + 2); // 6
});
test("estimateSizeFast handles circular references without infinite loop", () => {
const circular: Record<string, unknown> = { a: "test" };
circular.self = circular; // Create circular ref
// Should not hang — WeakSet skips already-visited objects
const result = estimateSizeFast(circular);
assert.equal(result, 4); // Only "test" (4) counted; circular ref skipped
});
test("estimateSizeFast handles deeply nested circular refs", () => {
const a: Record<string, unknown> = { val: "x" };
const b: Record<string, unknown> = { ref: a };
a.back = b;
const result = estimateSizeFast({ root: a });
assert.equal(result, 1); // "x" = 1
});
test("estimateSizeFast early-exits at 262144 bytes (256KB)", () => {
// Create a string > 256KB
const bigStr = "x".repeat(300_000);
const result = estimateSizeFast(bigStr);
assert.ok(result >= 262144, `Should early-exit, got ${result}`);
});
test("estimateSizeFast accepts a custom earlyExitAt so a raised caller threshold is actually reachable", () => {
// Bug: chatCore/logTruncation.ts's truncateForLog() compares estimateSizeFast's
// result against a configurable threshold (getChatLogMaxBodyBytes(), default
// 1MB) — but estimateSizeFast's own early-exit was hardcoded at 256KB. Many
// small chunks (the realistic shape — a message array) stop accumulating the
// instant the running total crosses the early-exit point, so with the old
// hardcoded 256KB exit the reported size could never signal "still under a
// 1MB threshold" for an object whose true size sits between the two.
const chunks = Array.from({ length: 6000 }, () => "x".repeat(100)); // ~600KB true size
const defaultResult = estimateSizeFast(chunks);
assert.ok(
defaultResult <= 262144 + 100,
`default earlyExitAt should stop accumulating around 256KB, got ${defaultResult}`
);
const oneMbResult = estimateSizeFast(chunks, 1024 * 1024);
assert.ok(
oneMbResult > 262144,
`with a 1MB earlyExitAt, ~600KB of chunks must be measurable past the old 256KB cap, got ${oneMbResult}`
);
assert.ok(
oneMbResult <= 1024 * 1024 + 100,
`should not exceed the true ~600KB size, got ${oneMbResult}`
);
});
test("estimateSizeFast handles mixed object/array nesting", () => {
const data = {
choices: [
{
delta: { content: "Hello world" },
index: 0,
},
],
};
// content=11, index=8 (number), delta keys: content+delta=7, choices=8
const result = estimateSizeFast(data);
assert.ok(result > 0);
assert.ok(result < 100);
});
test("estimateSizeFast does not count keys, only values", () => {
// Object with long keys but short values
const obj = { aLongKeyName: "x", anotherLongKeyName: "y" };
assert.equal(estimateSizeFast(obj), 2); // "x" + "y"
});
test("isSmallEnoughForSemanticCache returns true for small payloads", () => {
assert.ok(isSmallEnoughForSemanticCache({ msg: "hi" }));
});
test("isSmallEnoughForSemanticCache returns false for huge payloads", () => {
const huge = { data: "x".repeat(300_000) };
assert.ok(!isSmallEnoughForSemanticCache(huge));
});
test("isSmallEnoughForSemanticCache handles circular refs gracefully", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
// Should not hang; estimateSizeFast has WeakSet protection
const result = isSmallEnoughForSemanticCache(circular);
assert.equal(result, true); // 0 bytes < 256KB
});
test("estimateSizeFast handles Map-like objects (no infinite loop on iterables)", () => {
const map = new Map<string, unknown>([["key", "value"]]);
// Maps are objects but have no enumerable own properties via for-in
const result = estimateSizeFast(map);
assert.ok(typeof result === "number");
});