Files
OmniRoute/tests/unit/chatcore-log-truncation.test.ts
Markus Hartung beb6ec857b feat(dashboard): agentic conversation tracking — v4, decoupled + storage-architecture concern resolved (#10263)
* feat(responses): virtualize previous_response_id continuation regardless of upstream support

OmniRoute now exposes OpenAI-compatible previous_response_id/store
continuation to clients unconditionally, even when the selected upstream
provider has no native Responses-API state support. Reconstruction happens
server-side in handleChatImplementation, before any downstream validation
or provider translation: OmniRoute resolves the response id back to the
full input/output it previously produced, prepends it to the client's
delta, and forwards the full reconstructed history upstream exactly as it
does today. Client<->OmniRoute traffic shrinks to the new delta only;
OmniRoute<->provider traffic is unchanged.

Storage reuses the existing call-log pipeline artifact (already gated by
call_log_pipeline_enabled, already retained/cleaned up by the existing
call-log lifecycle) instead of duplicating conversation content into a
second store -- only a lightweight call_logs.response_id index is new.
Every lookup is scoped by api_key_id so one client can never resolve
another client's stored conversation, and any unresolvable/missing/
size-limit-omitted state fails closed with OpenAI's own
previous_response_not_found contract.

Stacked on feat/openai-responses-store-toggle (#10121).

* feat(dashboard): agentic conversation tracking with live transcript view

Every agentic chat request now gets a conversation id (X-ConversationId
response header). OmniRoute detects when a follow-up request continues the
same conversation via fingerprint + bounded prefix-hash matching, with a
strict-growth invariant to prevent false merges between independent
single-shot requests that happen to share identical opening content.
Continuation detection excludes the system message from the identity
anchor, since real coding-agent CLIs commonly regenerate it every request
with live context (timestamp, cwd, git status) — without this, that
volatility alone broke every continuation check against real traffic.

- `/dashboard/logs`: new toggleable Conversation column.
- `/dashboard/logs/timeline`: requests sharing a conversation id share a
  timeline lane, connected by an arrow, with a configurable lane-reuse
  window.
- Request detail panel: new Full Conversation transcript above the raw SSE
  event stream — Markdown rendering, per-turn timestamps, turn-relative
  view, click-any-turn navigation, live auto-refresh building the
  transcript in real time from the in-flight SSE chunk buffer while a
  request is still streaming, auto-scroll-to-bottom as the live turn grows.
- New `/dashboard/conversations` page listing conversations with 2+ turns,
  no-forking model (an edited/duplicated mid-history turn mints its own
  independent conversation instead of merging), pagination, duplicate-
  anchor fix.
- Configurable auto-refresh intervals on both the timeline and
  conversations list pages.
- Responses API tool-call gap fix: turnsFromOpenAiMessages only handled
  role-based Chat Completions messages, so bare {type:"function_call"} /
  {type:"function_call_output"} / {type:"reasoning"} items (real Responses
  API traffic) silently vanished from the Conversation Context panel.
- truncateForLog now counts input[] (Responses API), not just messages[]
  (Chat Completions), so a truncated /v1/responses request still shows a
  placeholder instead of nothing.
- RequestTimeline.tsx now reads the same debugEnabled/emailsVisible
  settings RequestLoggerV2.tsx already used, instead of hardcoding both
  false — the timeline view never showed SSE/stream-chunk events or
  respected email-masking, regardless of the actual setting.

Migrations 147/148 (agentic_conversations, conversation_turn_nodes) — 135
and 136 are now taken upstream; 143-145 are documented KNOWN_GAPS, so this
uses the next free slot past upstream's current highest.

Test plan:
- npm run typecheck:core — clean
- npm run lint — clean
- node --import tsx/esm scripts/check/check-migration-numbering.mjs — OK, 0 collisions
- 109 unit tests across the conversation-tracking, migration-renumber, and
  dashboard-wiring surface — 0 failures

* refactor(dashboard): reuse call-log artifacts for conversation transcript content

conversation_turn_nodes no longer stores turn text/tool-call content
(text_preview/block_kind/tool_name) -- it's identity-only now (id/parent/
content_hash), matching agentic_conversations' existing lightweight-index
shape. Every node's originating request is already fully captured by the
call-log pipeline artifact its last_correlation_id points at, so the
/dashboard/conversations tree view resolves each node's actual display
content on demand from there (open-sse/services/conversationTurnContent.ts),
re-running the same extractCanonicalTurns/hashTurnContent the write path
used and matching by content_hash, instead of duplicating conversation
content into a second store under a separate retention/gating policy. This
also drops the old 8000-char text_preview truncation entirely -- resolved
content is always full and untruncated.

The frontend contract is unchanged (tree API still returns
{textPreview, blockKind, toolName} per node), so the dashboard UI itself
(page.tsx, RequestLoggerDetail/RequestTimeline, sidebar, i18n) needed no
changes.

Renumbered the cherry-picked 147/148 migrations to 153/154 -- 147 now
collides with 147_api_keys_model_access_mode.sql, which landed on
release/v3.8.50 after this work was originally built.

Also includes a standalone, unrelated fix carried along from this rebase:
close isProviderModelHidden's missing function-body brace in
modelSelectModalHelpers.ts (separately landed as #10206).

Stacked on feat/responses-previous-response-id-virtualization (#3), which
is itself stacked on feat/openai-responses-store-toggle (#10121).

* fix(dashboard): resync conversation list on open so the live-text poll starts immediately

openConversation() seeded activeConversation (and therefore activeCallLogId,
which gates the live-partial-text poll effect) from whatever row snapshot the
list's own fixed-interval poll last produced. A conversation opened right
after a reply started streaming -- after that tick, before the next -- had
activeCallLogId still null, so the live-text poll never started; only a
subsequent background list-poll resync (already existed) picked it up,
which is why closing and reopening the same conversation "just worked".

loadConversations() is now a shared callback so openConversation can force
one immediately on open instead of waiting on pollSeconds.

Live-verified against omniroute-dev: opening a conversation mid-stream now
shows live reasoning on the first open.

* style: prettier formatting for conversationTurnContent.test.ts

* fix(db): close migration numbering gap left by decoupling from #3/#10262

153/154 (originally 154/155) were chosen back when this branch stacked on
top of the previous_response_id migration (153_call_logs_response_id.sql).
Decoupling removed that migration from this branch's history, leaving an
unused 153 slot that check-migration-numbering.test.ts correctly flags as
a gap.

* refactor(dashboard): split RequestTimeline/RequestLoggerDetail under the 1000-line file-size cap

Both files exceeded check-file-size's new-file cap after this PR's own
additions (RequestTimeline 1048, RequestLoggerDetail 1163). Extracted pure
non-component logic (types, constants, allocateLanes and its helpers) out
of RequestTimeline.tsx into RequestTimeline.utils.ts, and the two
self-contained presentational sub-components (PayloadSection,
ConversationContextSection + its private helper) out of
RequestLoggerDetail.tsx into RequestLoggerDetail.sections.tsx. No behavior
change; existing external imports (default exports, allocateLanes,
TimelineLog, CONVERSATION_LANE_REUSE_STORAGE_KEY) still resolve from the
original file paths.

* fix(db): renumber agentic-conversation migrations to clear 153 collision + sync migration-count docs

The refresh-merge of release/v3.8.50 exposed that the feature's three
migrations collided at slot 153 with the base's radar_local_model_state
(153) and its own call_logs_response_id. Migration runner enforces unique
numeric prefixes -> every DB init threw, red-ing Vitest, all Unit shards and
the DB-backed quality gates. Renumber the feature's pair to
155_agentic_conversations / 156_conversation_turn_nodes and move
call_logs_response_id to 154 (keeps 153_radar base-owned, preserves
agentic-before-turn_nodes ordering). Update SQL headers and the
154/156 references in feature code + tests.

Migration count is now 151 (was 148 stale in README/AGENTS/llm.txt) — sync
the doc counts to clear the docs-accuracy gate.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* fix(ui): drop unused CONVERSATION_LANE_REUSE_STORAGE_KEY re-export from RequestTimeline

Knip 6.32 (baseline 415) flags the public re-export of
CONVERSATION_LANE_REUSE_STORAGE_KEY from RequestTimeline.tsx as dead: no
external consumer imports it through that re-export (it is imported and
used directly from RequestTimeline.utils.ts inside the component). Removed
the unused re-export; the internal import stays. DEAD_TOTAL 416 -> 415,
back to the frozen baseline.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* fix(agentic-conversations): guard resolveConversationId, drop dead whole-chain export

- Wrap resolveConversationId() in try/catch in chat.ts, matching the
  defensive pattern used by every other best-effort side call nearby, so a
  DB hiccup in conversation tracking can't turn a working chat request into
  a hard failure.
- Remove getConversationTurnTree: knip's project scope excludes tests/**,
  so an export used only by tests can never register as used there. Swap
  its 8 test call sites to the paginated getConversationTurnPage (already
  the dashboard's canonical query) with a generous limit, collapsing to one
  query path instead of keeping a second whole-chain export alive solely
  for test convenience.
- Regenerate i18n llm.txt mirrors from root (pre-existing drift on this
  branch, unrelated to the above, caught by the docs-sync pre-commit gate).

Addresses PR review feedback.

* fix(i18n): close requestLogger conversation-column gap, fix domain-modules count drift

- fr.json, vi.json were missing requestLogger.columns.conversation (added
  in the conversation-tracking feature), failing i18n-vi-completeness.test.ts.
- docs/i18n/*/llm.txt mirrors still said 117 domain-specific files after an
  earlier rebase fixed the migration count but missed this companion number,
  failing check-docs-sync.mjs across all 42 locales.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(docs): restore PROXY_LOG_INCLUDE_IPS env/doc entries (env-doc-sync red)

.env.example and docs/reference/ENVIRONMENT.md were both missing the
PROXY_LOG_INCLUDE_IPS entry that src/lib/proxyLogger.ts already reads
(confirmed present at this branch's merge-base too, so this predates
the conversation-tracking work and is unrelated to it) -- the entry
was added on release/v3.8.50 after this branch's last sync and this
branch never picked it up. That gap red-lines
tests/unit/check-env-doc-sync.test.ts and
tests/unit/issue-7793-env-doc-sync-repro.test.ts (Unit Tests
fast-path 2/4 in CI). Restore both entries verbatim from the current
release/v3.8.50 tip -- no feature-code change.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: hartmark <hartmark@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 11:32:33 -03:00

287 lines
12 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(500) })),
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 captures a message count for Responses API bodies too (input[], not messages[])", () => {
// Live bug: a large /v1/responses request got summarized with NO count at
// all (messages/contents are OpenAI-chat/Gemini-only field names), so the
// "Full Conversation" dashboard panel had nothing to base its "N messages
// not shown" placeholder on for any Responses-API conversation, even
// though the exact same 8KB summarization applies to it.
const huge = {
model: "gpt-5",
stream: true,
input: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(500) })),
};
const summary = truncateForLog(huge) as Record<string, unknown>;
assert.equal(summary._truncated, true);
assert.equal(summary.messageCount, 50000);
});
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(500) })),
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(500) })),
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);
});
/**
* Real bug: the 8KB cap on logged request/response bodies was hardcoded,
* trivially exceeded by any real multi-turn agentic conversation — 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. CHAT_LOG_MAX_BODY_KB makes this
* configurable; this pins that truncateForLog() actually reads it (not a
* baked-in literal) by proving a payload just over the OLD 8KB default
* survives untouched under a raised limit, then gets summarized again once
* the limit is lowered below it.
*/
test("truncateForLog honors a configured CHAT_LOG_MAX_BODY_KB instead of a hardcoded cap", () => {
const saved = process.env.CHAT_LOG_MAX_BODY_KB;
const payload = {
model: "gpt-4o",
// ~12KB of content — comfortably over the old hardcoded 8KB cap.
messages: [{ role: "user", content: "x".repeat(12 * 1024) }],
};
try {
process.env.CHAT_LOG_MAX_BODY_KB = "1"; // 1KB — payload must be summarized
const summarized = truncateForLog(payload) as Record<string, unknown>;
assert.equal(summarized._truncated, true, "expected summarization under a 1KB limit");
process.env.CHAT_LOG_MAX_BODY_KB = "64"; // 64KB — payload must pass through untouched
const untouched = truncateForLog(payload);
assert.equal(untouched, payload, "expected the payload untouched under a 64KB limit");
} finally {
if (saved === undefined) delete process.env.CHAT_LOG_MAX_BODY_KB;
else process.env.CHAT_LOG_MAX_BODY_KB = saved;
}
});