Files
OmniRoute/tests/unit/multiRowConversation.test.ts
Markus Hartung 12228626c1 fix(conversation): show a placeholder for truncated bodies with no known message count
Follow-up to the earlier truncated-request-body transcript fix, found by
re-checking the live dashboard: a specific /v1/responses request still
showed nothing for its own turn even though its body genuinely was
truncated by logTruncation.ts's truncateForLog().

Root cause (two parts):

1. truncateForLog() only counted messages[] (Chat Completions) and
   contents[] (Gemini) — never input[] (Responses API) — so a truncated
   Responses API request's summary carried NO count field at all.

2. buildMultiRowConversation()'s earlier fix defaulted an unknown count to
   0, which silently produced "0 new turns" instead of surfacing that the
   count was simply unavailable — same end symptom as the original bug
   (nothing shown) despite the row being genuinely truncated.

Fixes:
- logTruncation.ts now also sets messageCount for input[] bodies (root
  fix, only helps requests logged from here forward).
- multiRowConversation.ts now distinguishes "known count" (existing
  specific "N messages not shown" placeholder + correct bookkeeping) from
  "unknown count" (a generic placeholder, since we can't safely diff
  against previousTotal without a real number) — needed for the
  already-persisted historical data on omniroute-dev that will never
  retroactively get a messageCount.

Test plan:
- New TDD tests for both gaps (Responses API count capture in
  logTruncation, unknown-count placeholder in multiRowConversation),
  confirmed failing before each fix and passing after
- npm run typecheck:core / npm run lint / npm run check:file-size — clean
- npm run test:unit — 27223 tests, same 4 pre-existing/unrelated failures
  as the last confirmed-clean run (no new regressions)
- npm run test:vitest — 291/291 passed
- Rebuilt and redeployed to omniroute-dev
2026-08-05 11:14:11 +02:00

241 lines
9.2 KiB
TypeScript

/**
* Unit tests for src/mitm/inspector/multiRowConversation.ts — the delta
* algorithm that reconstructs a chronological, per-row-tagged transcript
* across every call_logs row of one agentic conversation.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { buildMultiRowConversation } from "../../src/mitm/inspector/multiRowConversation.ts";
test("buildMultiRowConversation: 3-row conversation with a tool call in the middle", () => {
const rows = [
{
id: "A",
timestamp: "2026-01-01T10:00:00.000Z",
requestBody: { messages: [{ role: "user", content: "user1" }] },
responseBody: { choices: [{ message: { role: "assistant", content: "assistant1" } }] },
},
{
id: "B",
timestamp: "2026-01-01T10:01:00.000Z",
requestBody: {
messages: [
{ role: "user", content: "user1" },
{ role: "assistant", content: "assistant1" },
{ role: "tool", content: "tool_result1" },
{ role: "user", content: "user2" },
],
},
responseBody: { choices: [{ message: { role: "assistant", content: "assistant2" } }] },
},
{
id: "C",
timestamp: "2026-01-01T10:02:00.000Z",
requestBody: {
messages: [
{ role: "user", content: "user1" },
{ role: "assistant", content: "assistant1" },
{ role: "tool", content: "tool_result1" },
{ role: "user", content: "user2" },
{ role: "assistant", content: "assistant2" },
],
},
responseBody: { choices: [{ message: { role: "assistant", content: "assistant3" } }] },
},
];
const turns = buildMultiRowConversation(rows);
// Row A contributes: user1, assistant1
// Row B contributes: tool_result1, user2, assistant2 (new request turns + its own response)
// Row C contributes: assistant3 only (no new request turns — request already
// equals the running total after row B)
assert.deepEqual(
turns.map((t) => ({ sourceCallLogId: t.sourceCallLogId, role: t.role })),
[
{ sourceCallLogId: "A", role: "user" },
{ sourceCallLogId: "A", role: "assistant" },
{ sourceCallLogId: "B", role: "tool" },
{ sourceCallLogId: "B", role: "user" },
{ sourceCallLogId: "B", role: "assistant" },
{ sourceCallLogId: "C", role: "assistant" },
]
);
// Every turn carries the ISO timestamp of its own originating row.
assert.equal(turns[0].timestamp, "2026-01-01T10:00:00.000Z");
assert.equal(turns[2].timestamp, "2026-01-01T10:01:00.000Z");
assert.equal(turns[5].timestamp, "2026-01-01T10:02:00.000Z");
});
test("buildMultiRowConversation: single-row conversation", () => {
const rows = [
{
id: "solo",
timestamp: "2026-01-01T10:00:00.000Z",
requestBody: { messages: [{ role: "user", content: "hi" }] },
responseBody: { choices: [{ message: { role: "assistant", content: "hello" } }] },
},
];
const turns = buildMultiRowConversation(rows);
assert.equal(turns.length, 2);
assert.equal(turns[0].role, "user");
assert.equal(turns[0].sourceCallLogId, "solo");
assert.equal(turns[1].role, "assistant");
assert.equal(turns[1].sourceCallLogId, "solo");
});
test("buildMultiRowConversation: a later row that is NOT a superset (malformed/adversarial) clamps instead of throwing", () => {
const rows = [
{
id: "A",
timestamp: "2026-01-01T10:00:00.000Z",
requestBody: {
messages: [
{ role: "user", content: "user1" },
{ role: "assistant", content: "assistant1" },
{ role: "user", content: "user2" },
],
},
responseBody: { choices: [{ message: { role: "assistant", content: "assistant2" } }] },
},
{
// Shorter request than row A's total (4) despite sharing the same
// conversation id somehow — should never happen given
// resolveConversationId's strict-growth guarantee, but must not throw
// or produce a negative-length slice.
id: "B",
timestamp: "2026-01-01T10:01:00.000Z",
requestBody: { messages: [{ role: "user", content: "user1" }] },
responseBody: { choices: [{ message: { role: "assistant", content: "assistant3" } }] },
},
];
assert.doesNotThrow(() => buildMultiRowConversation(rows));
const turns = buildMultiRowConversation(rows);
// Row B's own response is still included; it just contributes no new
// request turns since it's shorter than what's already been seen.
assert.ok(turns.some((t) => t.sourceCallLogId === "B"));
});
test("buildMultiRowConversation: empty rows array returns an empty transcript", () => {
assert.deepEqual(buildMultiRowConversation([]), []);
});
test("buildMultiRowConversation: a truncated request body (>8KB, dropped by truncateForLog) shows a placeholder instead of silently only the response", () => {
// Live bug: a request with a long real history (332 messages) got its
// requestBody replaced by open-sse/handlers/chatCore/logTruncation.ts's
// truncateForLog() with a bare {_truncated, _originalBytes, messageCount,
// ...} summary once it crossed ~8KB — which is the norm, not the
// exception, for any conversation with real substance. Before this fix,
// buildRequestTurns() found nothing to parse and the transcript silently
// rendered only that row's own response — looking exactly like "just the
// last line" of what was actually a long chain.
const rows = [
{
id: "big",
timestamp: "2026-01-01T10:00:00.000Z",
requestBody: {
_truncated: true,
_originalBytes: 263193,
model: "big-pickle",
stream: true,
messageCount: 332,
},
responseBody: { choices: [{ message: { role: "assistant", content: "final reply" } }] },
},
];
const turns = buildMultiRowConversation(rows);
assert.equal(turns.length, 2, "expected a placeholder turn plus the response turn");
assert.equal(turns[0].role, "system");
assert.equal(turns[0].sourceCallLogId, "big");
assert.match(turns[0].blocks[0].type === "text" ? turns[0].blocks[0].text : "", /332/);
assert.equal(turns[1].role, "assistant");
});
test("buildMultiRowConversation: a truncated row's messageCount keeps a LATER real row's delta bookkeeping correct", () => {
const rows = [
{
id: "big",
timestamp: "2026-01-01T10:00:00.000Z",
requestBody: {
_truncated: true,
_originalBytes: 263193,
messageCount: 5,
},
responseBody: { choices: [{ message: { role: "assistant", content: "reply1" } }] },
},
{
// Real history: the 5 earlier (unrecoverable) messages + reply1 (6) +
// one genuinely new user turn (7 total). Only that new turn + this
// row's own response should render — not the 5 unrecoverable messages
// re-counted as "new" just because their content was never seen.
id: "next",
timestamp: "2026-01-01T10:01:00.000Z",
requestBody: {
messages: [
{ role: "user", content: "m1" },
{ role: "assistant", content: "m2" },
{ role: "user", content: "m3" },
{ role: "assistant", content: "m4" },
{ role: "user", content: "m5" },
{ role: "assistant", content: "reply1" },
{ role: "user", content: "one more thing" },
],
},
responseBody: { choices: [{ message: { role: "assistant", content: "reply2" } }] },
},
];
const turns = buildMultiRowConversation(rows);
const nextRowTurns = turns.filter((t) => t.sourceCallLogId === "next");
assert.equal(
nextRowTurns.length,
2,
`expected exactly the one new user turn + this row's response, got: ${JSON.stringify(nextRowTurns)}`
);
assert.equal(nextRowTurns[0].role, "user");
assert.equal(
nextRowTurns[0].blocks[0].type === "text" ? nextRowTurns[0].blocks[0].text : "",
"one more thing"
);
assert.equal(nextRowTurns[1].role, "assistant");
});
test("buildMultiRowConversation: a truncated body with NO messageCount at all (older data, or a body shape truncateForLog() doesn't count) still shows a generic placeholder", () => {
// Live bug: a /v1/responses request's truncated summary had no count field
// at all (truncateForLog() only counted `messages[]`, not Responses API's
// `input[]`, until a companion fix) — the previous version of this
// function silently treated an unknown count as 0 new turns, so the
// transcript rendered NOTHING for the request side, same end symptom as
// the original bug report despite the row genuinely being truncated.
const rows = [
{
id: "responses-big",
timestamp: "2026-01-01T10:00:00.000Z",
requestBody: {
_truncated: true,
_originalBytes: 300000,
model: "gpt-5",
stream: true,
// no messageCount field at all
},
responseBody: { choices: [{ message: { role: "assistant", content: "final reply" } }] },
},
];
const turns = buildMultiRowConversation(rows);
assert.equal(turns.length, 2, "expected a generic placeholder plus the response turn");
assert.equal(turns[0].role, "system");
assert.equal(turns[0].sourceCallLogId, "responses-big");
assert.equal(
turns[0].blocks[0].type === "text" ? turns[0].blocks[0].text : "",
"Earlier messages not shown — the request body was too large to log."
);
assert.equal(turns[1].role, "assistant");
});