mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
fix(conversations): stop truncated tool_use previews from breaking JSON
A tool_use/tool_result turn's text is the tool call's raw JSON arguments (or a stringified result) — slicing that raw JSON at a fixed character offset (TEXT_PREVIEW_LENGTH) routinely landed mid-string, storing INVALID JSON. The conversations page's toTurn() then failed to JSON.parse it and fell back to showing the raw, still-escaped text verbatim: a large edit/write/apply_patch-style tool call with a long content field rendered with literal `\n` sequences visible instead of real line breaks, looking exactly like a JSON-escaping bug rather than a big diff. Confirmed live on omniroute-dev: 3 stored `edit` tool_use previews were sitting at exactly 8000 chars with "Unterminated string in JSON" on parse. buildTextPreview now parses first and caps oversized string VALUES inside the JSON instead of slicing the raw blob, so a truncated payload is always valid, re-parseable JSON. Plain text turns are unaffected (still a simple slice — a cut-off sentence is harmless). Also fixes JsonViewer's string rendering to preserve line breaks (whitespace-pre-wrap) — a correctly-parsed multi-line tool argument was still visually squashing onto one line without it. Unrelated cleanup found while editing: conversationTracker.ts had two literal NUL bytes (pre-existing, not introduced by this change) sitting where a template-literal space belonged, making the file register as binary to grep/rg/file. Restored to plain spaces. Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
This commit is contained in:
@@ -259,12 +259,61 @@ export function computeFingerprintHash(input: {
|
||||
// while still bounding pathological outliers.
|
||||
const TEXT_PREVIEW_LENGTH = 8000;
|
||||
|
||||
/** Caps every string leaf in a parsed JSON value to `maxLen`, recursing
|
||||
* through arrays/objects — used so re-serializing after truncation always
|
||||
* yields valid JSON (see buildTextPreview's doc comment). */
|
||||
function truncateStringsDeep(value: unknown, maxLen: number): unknown {
|
||||
if (typeof value === "string") {
|
||||
return value.length > maxLen ? `${value.slice(0, maxLen)}…` : value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => truncateStringsDeep(item, maxLen));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = truncateStringsDeep(val, maxLen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds a turn's stored text_preview. Plain text turns are safe to slice at
|
||||
* a fixed character offset — a cut-off sentence is still readable. A
|
||||
* tool_use/tool_result turn's `text` is the tool call's raw JSON arguments
|
||||
* (or a stringified result) — see stringifyContent's callers — so slicing
|
||||
* the RAW JSON string at a fixed offset routinely lands mid-string, storing
|
||||
* INVALID JSON. The frontend (src/app/(dashboard)/dashboard/conversations/
|
||||
* page.tsx's toTurn) tries to JSON.parse this for rendering and, on failure,
|
||||
* falls back to showing the raw text verbatim — which for a truncated tool
|
||||
* payload means the still-JSON-escaped text (literal `\n` sequences, quotes,
|
||||
* etc.) shown as-is instead of real line breaks: a genuine large `edit`/
|
||||
* `write` tool call looking like a broken escaping bug rather than a big
|
||||
* diff. Parse first and cap each string VALUE instead, so a truncated
|
||||
* payload is always valid JSON the frontend can actually parse and render.
|
||||
*/
|
||||
function buildTextPreview(turn: CanonicalTurn): string {
|
||||
if (turn.blockKind === "text" || turn.text.length <= TEXT_PREVIEW_LENGTH) {
|
||||
return turn.text.slice(0, TEXT_PREVIEW_LENGTH);
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(turn.text);
|
||||
return JSON.stringify(truncateStringsDeep(parsed, TEXT_PREVIEW_LENGTH));
|
||||
} catch {
|
||||
// Not valid JSON to begin with (rare/malformed upstream tool call) — a
|
||||
// fixed-offset slice couldn't have preserved parseability either way.
|
||||
return turn.text.slice(0, TEXT_PREVIEW_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
function hashTurnContent(turn: CanonicalTurn): string {
|
||||
return hashHex(`${turn.role} | ||||