Files
OmniRoute/tests/unit/multiRowConversation.test.ts
Markus Hartung b954a1cf76 fix(ci): resolve migration collision, file-size gate, and truncated-body transcript bug
CI failures on PR #9439:

1. Migration version collision: upstream/release/v3.8.50 landed
   134_proxy_logs_egress_ip.sql (#9291) after this branch's original rebase,
   colliding with this branch's own 134_agentic_conversations.sql. Renamed
   to 135_agentic_conversations.sql (re-rebased onto the current tip first).

2. check:file-size: rebaselined the files this PR's own feature growth pushed
   over their frozen/cap thresholds (RequestLoggerDetail.tsx, RequestTimeline.tsx,
   RequestLoggerV2.tsx, chat.ts, chatCore.ts — see the new
   _rebaseline_2026_08_04_9439 entry for the itemized justification) plus
   open-sse/executors/base.ts, which was already over its own frozen baseline
   on release/v3.8.50 independent of this branch (confirmed via `git diff
   upstream/release/v3.8.50 HEAD -- open-sse/executors/base.ts` — empty).

3. A third real bug, found by re-checking the live dashboard after the
   previous round's fixes: a request with a long real conversation chain
   showed only its own response in the "Full Conversation" panel. Root
   cause: open-sse/handlers/chatCore/logTruncation.ts's truncateForLog()
   replaces any request body over ~8KB with a bare {_truncated,
   _originalBytes, messageCount, ...} summary, dropping messages/input
   entirely — the norm, not the exception, for any conversation with real
   substance. buildRequestTurns() legitimately found nothing to parse, so
   the transcript silently rendered only that row's response, and (more
   subtly) every subsequent row's delta-slicing bookkeeping was computed
   against the wrong running total (0 instead of the row's real turn
   count), which would have corrupted the rest of the reconstruction too
   for any longer chain built on top of a truncated row.

   Fix: buildMultiRowConversation now detects a truncated request body via
   its messageCount field, uses that count for delta bookkeeping instead of
   silently treating it as zero, and renders one explicit placeholder turn
   ("N messages not shown — the request body was too large to log")
   instead of just disappearing.

Test plan:
- New regression tests for the truncation case (single truncated row, and
  a truncated row followed by a real row to verify bookkeeping stays
  correct)
- npm run check:migration-numbering / check:file-size — clean
- npm run typecheck:core / npm run lint — clean
- npm run test:unit — 27086 tests, only 4 failures remain (down from 18 —
  2 were fixed by the newer upstream commits pulled in by this re-rebase),
  all independently pre-existing/unrelated (ServiceSupervisor timing,
  monaco-editor path, npm-pack)
- npm run test:vitest — 291/291 passed
2026-08-05 11:14:11 +02:00

208 lines
7.8 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");
});