Files
OmniRoute/tests/unit/conversationTracker.test.ts
Markus Hartung dac626026e 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>
2026-08-06 14:50:35 +02:00

701 lines
27 KiB
TypeScript

/**
* Unit tests for the agentic conversation tracker
* (open-sse/services/conversationTracker.ts).
*/
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-conv-tracker-"));
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "conversation-tracker-test-secret";
import {
extractCanonicalTurns,
computeFingerprintHash,
resolveConversationId,
} from "../../open-sse/services/conversationTracker.ts";
import { getConversationTurnTree } from "../../src/lib/db/agenticConversations.ts";
let correlationCounter = 0;
function nextCorrelationId(): string {
correlationCounter += 1;
return `corr-${correlationCounter}`;
}
test("extractCanonicalTurns: OpenAI messages array", () => {
const turns = extractCanonicalTurns({
messages: [
{ role: "system", content: "be helpful" },
{ role: "user", content: "hi" },
{ role: "assistant", content: "hello!" },
],
});
assert.deepEqual(
turns.map((t) => t.role),
["system", "user", "assistant"]
);
assert.equal(turns[0].text, "be helpful");
});
test("extractCanonicalTurns: Responses API input array", () => {
const turns = extractCanonicalTurns({
input: [
{ role: "user", content: [{ type: "input_text", text: "check the file" }] },
{ type: "function_call", name: "exec", call_id: "c1", arguments: '{"command":"ls"}' },
{ type: "function_call_output", call_id: "c1", output: "ok" },
],
});
assert.equal(turns.length, 3);
assert.equal(turns[0].role, "user");
// Regression: content-block arrays (Responses API's `input_text`/
// `output_text` shape) must extract their `.text`, not JSON.stringify the
// whole block array — a raw JSON blob here directly becomes what
// /dashboard/conversations renders as a turn's text.
assert.equal(turns[0].text, "check the file");
assert.equal(turns[1].role, "tool");
// `arguments` here is already a JSON string (how OpenAI/Responses API send
// tool-call arguments) — stringifyContent passes strings through as-is,
// only the content-BLOCK-ARRAY case (turns[0] above) needed the fix.
assert.equal(turns[1].text, '{"command":"ls"}');
assert.equal(turns[2].role, "tool");
assert.equal(turns[2].text, "ok");
// blockKind/toolName let a consumer (the /dashboard/conversations tree)
// build the same NormalizedBlock shape the request-detail panel already
// builds, so tool calls/results render through the same ChatBubble/
// MessageContent/ToolCallBlock/ToolResultBlock components everywhere.
assert.equal(turns[0].blockKind, "text");
assert.equal(turns[0].toolName, null);
assert.equal(turns[1].blockKind, "tool_use");
assert.equal(turns[1].toolName, "exec");
assert.equal(turns[2].blockKind, "tool_result");
assert.equal(turns[2].toolName, null);
});
test("extractCanonicalTurns: Chat Completions tool-result message (role: tool) classifies as tool_result", () => {
const turns = extractCanonicalTurns({
messages: [
{ role: "user", content: "what's the weather?" },
{ role: "tool", tool_call_id: "c1", content: '{"tempC":21}' },
],
});
assert.equal(turns[0].blockKind, "text");
assert.equal(turns[1].role, "tool");
assert.equal(turns[1].blockKind, "tool_result");
assert.equal(turns[1].text, '{"tempC":21}');
});
test("extractCanonicalTurns: content-block arrays (Anthropic/Responses-API shape) extract text, not raw JSON", () => {
const turns = extractCanonicalTurns({
messages: [
{ role: "user", content: [{ type: "text", text: "hello there" }] },
{ role: "assistant", content: [{ type: "output_text", text: "hi back" }] },
],
});
assert.equal(turns[0].text, "hello there");
assert.equal(turns[1].text, "hi back");
assert.ok(!turns[0].text.includes("{"), "must not contain raw JSON");
assert.ok(!turns[1].text.includes("{"), "must not contain raw JSON");
});
test("extractCanonicalTurns: Responses API bare-string input", () => {
const turns = extractCanonicalTurns({ input: "just a string" });
assert.equal(turns.length, 1);
assert.equal(turns[0].role, "user");
assert.equal(turns[0].text, "just a string");
});
test("computeFingerprintHash: same inputs produce the same hash", () => {
const a = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: [] });
const b = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: [] });
assert.equal(a, b);
});
test("computeFingerprintHash: different apiKeyId or model changes the hash", () => {
const base = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: [] });
const diffKey = computeFingerprintHash({ apiKeyId: "key2", model: "gpt-4o", toolNames: [] });
const diffModel = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-5", toolNames: [] });
assert.notEqual(base, diffKey);
assert.notEqual(base, diffModel);
});
test("computeFingerprintHash: identical apiKeyId/model/toolNames produce the same hash regardless of message content", () => {
// The whole point of the fix: real OpenClaw traffic rotates its earliest
// turns out of a sliding context window, so the bucket key must not
// depend on message text at all — actual identity is decided later by the
// turn-chain walk (real content overlap), not by this coarse bucket.
const a = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: ["exec"] });
const b = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: ["exec"] });
assert.equal(a, b);
});
test("resolveConversationId: stores clean extracted text_preview for content-block turns, not raw JSON (real OpenClaw/Responses-API traffic shape)", async () => {
const apiKeyId = "key-preview-json-bug";
const turn = await resolveConversationId({
body: {
model: "big-pickle",
input: [{ role: "user", content: [{ type: "input_text", text: "hello there" }] }],
},
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
const tree = getConversationTurnTree(turn.conversationId);
assert.equal(tree.length, 1);
assert.equal(tree[0].textPreview, "hello there");
assert.ok(
!tree[0].textPreview.includes("{"),
"text_preview must be the extracted turn text, not a JSON-stringified content-block array"
);
});
test("resolveConversationId: exact-match continuation reuses the same id", async () => {
const apiKeyId = "key-exact";
const turn1 = await resolveConversationId({
body: { model: "big-pickle", messages: [{ role: "user", content: "hi there" }] },
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.equal(turn1.isNewConversation, true);
const turn2 = await resolveConversationId({
body: {
model: "big-pickle",
messages: [
{ role: "user", content: "hi there" },
{ role: "assistant", content: "hello!" },
{ role: "user", content: "tell me more" },
],
},
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.equal(turn2.conversationId, turn1.conversationId);
assert.equal(turn2.isNewConversation, false);
});
test("resolveConversationId: prefix-match continuation across a longer history", async () => {
const apiKeyId = "key-prefix";
const turn1 = await resolveConversationId({
body: { model: "big-pickle", messages: [{ role: "user", content: "prefix test start" }] },
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
// Turn 3 resends the full history including turn 2's exchange — still a
// continuation of turn 1's conversation even though it's grown further.
const turn3 = await resolveConversationId({
body: {
model: "big-pickle",
messages: [
{ role: "user", content: "prefix test start" },
{ role: "assistant", content: "ack" },
{ role: "tool", content: "tool result" },
{ role: "assistant", content: "done" },
{ role: "user", content: "and one more thing" },
],
},
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.equal(turn3.conversationId, turn1.conversationId);
});
test("resolveConversationId: an edited/duplicated mid-history turn mints its own independent conversation (2026-08-06 redesign — no forking)", async () => {
// The scenario that originally motivated the hash-chain rewrite, and now
// motivates the no-forking redesign: OpenClaw-style cache-aware context
// injection edits turn `c` to `c'` and duplicates turn `i` with an
// injected variant `i'` ahead of it, between two otherwise-related
// requests:
// request 1: a b c d e f g h i
// request 2: a b c' d e f g h i' i j k
// `a`/`b` are byte-identical, but every OmniRoute conversation is a single
// straight line — it never forks. So request 2 must become its OWN
// independent conversation (not request1's), with its OWN complete chain
// (a b c' d e f g h i' i j k), and request1's chain must stay untouched.
const apiKeyId = "key-fork";
const request1 = await resolveConversationId({
body: {
model: "big-pickle",
messages: [
{ role: "user", content: "a" },
{ role: "assistant", content: "b" },
{ role: "user", content: "c" },
{ role: "assistant", content: "d" },
{ role: "user", content: "e" },
{ role: "assistant", content: "f" },
{ role: "user", content: "g" },
{ role: "assistant", content: "h" },
{ role: "user", content: "i" },
],
},
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.equal(request1.isNewConversation, true);
const request2 = await resolveConversationId({
body: {
model: "big-pickle",
messages: [
{ role: "user", content: "a" },
{ role: "assistant", content: "b" },
{ role: "user", content: "c'" },
{ role: "assistant", content: "d" },
{ role: "user", content: "e" },
{ role: "assistant", content: "f" },
{ role: "user", content: "g" },
{ role: "assistant", content: "h" },
{ role: "user", content: "i'" },
{ role: "assistant", content: "i" },
{ role: "user", content: "j" },
{ role: "assistant", content: "k" },
],
},
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
// A distinct, brand-new conversation — not request1's.
assert.notEqual(request2.conversationId, request1.conversationId);
assert.equal(request2.isNewConversation, true);
// request1's chain is completely untouched: still exactly its own 9 turns.
const tree1 = getConversationTurnTree(request1.conversationId);
assert.equal(tree1.length, 9);
assert.deepEqual(
tree1.map((n) => n.textPreview).sort(),
["a", "b", "c", "d", "e", "f", "g", "h", "i"].sort()
);
// request2's chain is its own complete, independent 12-turn history —
// including its OWN copies of "a" and "b" (different node ids than
// request1's, since each conversation's chain hashing is scoped to its
// own conversation id), not references into request1's chain.
const tree2 = getConversationTurnTree(request2.conversationId);
assert.equal(tree2.length, 12);
assert.deepEqual(
tree2.map((n) => n.textPreview).sort(),
["a", "b", "c'", "d", "e", "f", "g", "h", "i'", "i", "j", "k"].sort()
);
const ids1 = new Set(tree1.map((n) => n.id));
const ids2 = new Set(tree2.map((n) => n.id));
for (const id of ids2) {
assert.ok(!ids1.has(id), "the two conversations must not share any node ids");
}
// A repeat of request2's exact history continues request2 (not a THIRD
// conversation) — the redesign doesn't mint a new id on every retry of an
// already-diverged chain.
const request2Retry = await resolveConversationId({
body: {
model: "big-pickle",
messages: [
{ role: "user", content: "a" },
{ role: "assistant", content: "b" },
{ role: "user", content: "c'" },
{ role: "assistant", content: "d" },
{ role: "user", content: "e" },
{ role: "assistant", content: "f" },
{ role: "user", content: "g" },
{ role: "assistant", content: "h" },
{ role: "user", content: "i'" },
{ role: "assistant", content: "i" },
{ role: "user", content: "j" },
{ role: "assistant", content: "k" },
{ role: "user", content: "l" },
],
},
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.equal(request2Retry.conversationId, request2.conversationId);
assert.equal(request2Retry.isNewConversation, false);
});
test("resolveConversationId: continuation is detected even when the system prompt is regenerated every turn (dynamic CLI boilerplate)", async () => {
// Real coding-agent CLIs (Claude Code, opencode, etc.) commonly regenerate
// the system prompt on EVERY request with live context (timestamp, cwd,
// git status...). The chain must exclude the system message entirely, or
// that volatility alone breaks continuation detection for real traffic —
// every turn would mint a brand new conversation id, even though
// apiKeyId/model/toolNames and the actual user/assistant history are
// unchanged. Discovered live on a real deployment (#9315 follow-up): 28
// consecutive requests from one growing session, each with turn_count=1.
const apiKeyId = "key-volatile-system";
const dynamicSystem = (n: number) =>
`You are an agent. Current time: 2026-08-04T12:0${n}:00Z. cwd: /home/user/project`;
const turn1 = await resolveConversationId({
body: {
model: "big-pickle",
messages: [
{ role: "system", content: dynamicSystem(0) },
{ role: "user", content: "please fix the bug in foo.ts" },
],
},
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.equal(turn1.isNewConversation, true);
const turn2 = await resolveConversationId({
body: {
model: "big-pickle",
messages: [
// System prompt regenerated with a DIFFERENT timestamp — everything
// else (apiKeyId, model, tool set, actual conversation content) is
// identical/growing normally.
{ role: "system", content: dynamicSystem(1) },
{ role: "user", content: "please fix the bug in foo.ts" },
{ role: "assistant", content: "Sure, I'll look at it." },
{ role: "user", content: "thanks, also check bar.ts" },
],
},
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.equal(
turn2.conversationId,
turn1.conversationId,
"expected turn2 to be recognized as a continuation despite the regenerated system prompt"
);
assert.equal(turn2.isNewConversation, false);
// The regenerated system prompt must never appear as a chain node.
const tree = getConversationTurnTree(turn1.conversationId);
for (const node of tree) {
assert.notEqual(node.role, "system");
}
});
test("resolveConversationId: continuation is detected even when the earliest turns rotate out of a sliding context window (live OpenClaw traffic pattern)", async () => {
// Discovered live on a real deployment: OpenClaw drops/summarizes the
// EARLIEST turns as a session grows (to bound context size), so the
// request's first non-system turn is a DIFFERENT piece of text on every
// single request — not just an edited/duplicated turn somewhere in the
// middle (that's the fork scenario above), but the very first turn the
// fingerprint bucket used to anchor on. If the bucket depends on that text
// at all, findAgenticConversationsByFingerprint returns zero candidates
// and the turn-chain match never even runs — the conversation looks
// "new" forever, the exact symptom this whole test file guards against.
const apiKeyId = "key-sliding-window";
const toolNames = ["exec"];
const turn1 = await resolveConversationId({
body: {
model: "big-pickle",
tools: [{ name: "exec" }],
messages: [
{ role: "user", content: "turn-A-oldest" },
{ role: "assistant", content: "turn-B" },
{ role: "user", content: "turn-C-shared-tail" },
],
},
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.equal(turn1.isNewConversation, true);
// Turn 2: the oldest turns ("turn-A-oldest", "turn-B") are gone, replaced
// by an unrelated summary — only "turn-C-shared-tail" onward survived.
const turn2 = await resolveConversationId({
body: {
model: "big-pickle",
tools: [{ name: "exec" }],
messages: [
{ role: "user", content: "[context summary, unrelated to turn-A/turn-B text]" },
{ role: "user", content: "turn-C-shared-tail" },
{ role: "assistant", content: "turn-D" },
{ role: "user", content: "turn-E" },
],
},
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.equal(
turn2.conversationId,
turn1.conversationId,
"expected turn2 to be recognized as a continuation despite the first turn's text changing entirely"
);
assert.equal(turn2.isNewConversation, false);
// Confirmed via the fingerprint itself: identical apiKeyId/model/toolNames
// (the only inputs to computeFingerprintHash now) despite completely
// different message content between the two requests.
const fp1 = computeFingerprintHash({ apiKeyId, model: "big-pickle", toolNames });
const fp2 = computeFingerprintHash({ apiKeyId, model: "big-pickle", toolNames });
assert.equal(fp1, fp2);
});
test("resolveConversationId: continuation is detected even when the reconnect turn's content is duplicated earlier in the chain (tool-polling loop)", async () => {
// Discovered live: real agentic traffic (a tool-polling loop, "ack"/"poll"
// repeated many times — one real conversation had 28 byte-identical copies
// of a single turn) leaves MANY existing nodes sharing the same content
// hash. When a sliding context window means the new request's earliest
// retained turn is one of these repeated turns, findReconnectMatch must
// not just grab whichever occurrence happens to be tried first (the
// oldest, per SQLite's insertion-order return) — that stale occurrence's
// recorded next-turn differs from the new content, so it looks like a
// divergence even though the TRUE tail occurrence (no recorded child yet)
// would extend cleanly. This is what made a real conversation mint a
// brand-new copy of its entire history on every single request instead of
// ever reconnecting (2026-08-06).
const apiKeyId = "key-dup-content";
const turn1 = await resolveConversationId({
body: {
model: "big-pickle",
messages: [
{ role: "user", content: "start" },
{ role: "assistant", content: "a1" },
{ role: "user", content: "ack" },
{ role: "assistant", content: "poll" },
{ role: "user", content: "ack" },
{ role: "assistant", content: "poll" },
{ role: "user", content: "ack" },
{ role: "assistant", content: "poll" },
],
},
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.equal(turn1.isNewConversation, true);
// Sliding window: only the last "ack"/"poll" pair survived, followed by
// genuinely new content. "ack" and "poll" each match 3 existing nodes.
const turn2 = await resolveConversationId({
body: {
model: "big-pickle",
messages: [
{ role: "user", content: "ack" },
{ role: "assistant", content: "poll" },
{ role: "user", content: "brand new turn" },
],
},
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.equal(
turn2.conversationId,
turn1.conversationId,
"expected turn2 to reconnect to turn1's conversation via the TRUE tail occurrence of the repeated ack/poll turns, not mint a new one"
);
assert.equal(turn2.isNewConversation, false);
const tree = getConversationTurnTree(turn1.conversationId);
assert.equal(
tree.length,
9,
"the new turn should be appended, not a whole new duplicate history"
);
assert.ok(tree.some((n) => n.textPreview === "brand new turn"));
});
test("resolveConversationId: different api keys never merge, even with byte-identical content", async () => {
// Fingerprint isolation (apiKeyId is part of computeFingerprintHash) is
// the actual multi-tenant boundary — must hold regardless of the turn
// chain's own content-addressing.
const body = { model: "big-pickle", messages: [{ role: "user", content: "hi" }] };
const first = await resolveConversationId({
body,
model: "big-pickle",
apiKeyId: "key-tenant-a",
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
const second = await resolveConversationId({
body,
model: "big-pickle",
apiKeyId: "key-tenant-b",
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.notEqual(second.conversationId, first.conversationId);
const fingerprintA = computeFingerprintHash({
apiKeyId: "key-tenant-a",
model: "big-pickle",
toolNames: [],
});
const fingerprintB = computeFingerprintHash({
apiKeyId: "key-tenant-b",
model: "big-pickle",
toolNames: [],
});
assert.notEqual(fingerprintA, fingerprintB);
});
test("resolveConversationId: a byte-identical repeat of a single-turn request continues the same conversation", async () => {
// Content-addressed nodes mean a byte-identical opener from the SAME
// apiKey/model (a client retry, or a genuinely separate session that also
// just says "hi") fully matches the existing 1-turn chain — nothing
// diverges (there's no turn afterward to disagree on yet), so this is a
// real continuation, not a fork candidate at all.
const apiKeyId = "key-repeated-singleshot";
const body = { model: "big-pickle", messages: [{ role: "user", content: "hi" }] };
const first = await resolveConversationId({
body,
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
const second = await resolveConversationId({
body,
model: "big-pickle",
apiKeyId,
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
assert.equal(first.isNewConversation, true);
assert.equal(second.conversationId, first.conversationId);
assert.equal(second.isNewConversation, false);
const tree = getConversationTurnTree(first.conversationId);
assert.equal(tree.length, 1);
});
test("resolveConversationId: client-supplied X-Omniroute-Session-Id wins outright", async () => {
const headerValue = "client-pinned-session-abc";
const first = await resolveConversationId({
body: { model: "big-pickle", messages: [{ role: "user", content: "conversation A" }] },
model: "big-pickle",
apiKeyId: "key-header",
clientSessionIdHeader: headerValue,
correlationId: nextCorrelationId(),
});
assert.equal(first.conversationId, headerValue);
// A second, otherwise-unrelated conversation sending the SAME header value
// merges under that one id — the header is authoritative, no heuristic
// check runs at all.
const second = await resolveConversationId({
body: { model: "gpt-4o", messages: [{ role: "user", content: "conversation B, unrelated" }] },
model: "gpt-4o",
apiKeyId: "key-header-2",
clientSessionIdHeader: headerValue,
correlationId: nextCorrelationId(),
});
assert.equal(second.conversationId, headerValue);
});
test("resolveConversationId: a plain text turn over the preview bound is simply sliced", async () => {
const longText = "y".repeat(9000);
const turn = await resolveConversationId({
body: { model: "big-pickle", messages: [{ role: "user", content: longText }] },
model: "big-pickle",
apiKeyId: "key-long-text",
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
const tree = getConversationTurnTree(turn.conversationId);
assert.equal(tree.length, 1);
assert.equal(tree[0].textPreview.length, 8000);
assert.equal(tree[0].textPreview, longText.slice(0, 8000));
});
// Real bug (2026-08-06): a tool_use turn's `text` is the tool call's raw
// JSON `arguments` string (see extractCanonicalTurns/stringifyContent — a
// JSON string is passed through as-is, never parsed). Slicing that raw JSON
// at a fixed character offset routinely lands mid-string, storing INVALID
// JSON — /dashboard/conversations page.tsx's toTurn() then fails to
// JSON.parse it and falls back to showing the raw, still-escaped text
// verbatim: a large `edit`/`write`/apply_patch-style tool call with a long
// `content`/`new_string` field renders with literal `\n` sequences visible
// instead of real line breaks, looking exactly like a JSON-escaping bug.
test("resolveConversationId: an oversized tool_use turn stores truncated but still VALID JSON", async () => {
const bigContent = "line one\nline two\n".repeat(1000); // well over 8000 chars
const args = JSON.stringify({ path: "/tmp/big.md", content: bigContent });
assert.ok(
args.length > 8000,
"the raw arguments JSON must exceed the preview bound for this test"
);
const turn = await resolveConversationId({
body: {
model: "big-pickle",
input: [{ type: "function_call", name: "write", call_id: "c1", arguments: args }],
},
model: "big-pickle",
apiKeyId: "key-big-tool-call",
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
const tree = getConversationTurnTree(turn.conversationId);
assert.equal(tree.length, 1);
assert.equal(tree[0].blockKind, "tool_use");
// The critical assertion: whatever got stored must be re-parseable JSON,
// never a mid-string cut fragment.
let parsed: { path?: string; content?: string } | undefined;
assert.doesNotThrow(() => {
parsed = JSON.parse(tree[0].textPreview);
}, "stored text_preview for a tool_use turn must always be valid, parseable JSON");
assert.equal(parsed?.path, "/tmp/big.md");
assert.ok(typeof parsed?.content === "string" && parsed.content.length > 0);
// The oversized string value was capped, not the whole serialized blob —
// real newlines inside it must survive (they did before truncation too).
assert.ok(parsed!.content!.includes("\n"));
});
test("resolveConversationId: a tool_use turn already within the preview bound is stored untouched", async () => {
const args = JSON.stringify({ command: "ls -la" });
const turn = await resolveConversationId({
body: {
model: "big-pickle",
input: [{ type: "function_call", name: "exec", call_id: "c1", arguments: args }],
},
model: "big-pickle",
apiKeyId: "key-small-tool-call",
clientSessionIdHeader: null,
correlationId: nextCorrelationId(),
});
const tree = getConversationTurnTree(turn.conversationId);
assert.equal(tree[0].textPreview, args);
});