Files
OmniRoute/tests/unit/multiRowConversation.test.ts
Markus Hartung 47b4a55f2d feat(dashboard): add agentic conversation tracking with live transcript view
Every agentic chat request now gets a conversation id (X-ConversationId
response header), and 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.

Dashboard changes:
- /dashboard/logs: toggleable Conversation column
- /dashboard/logs/timeline: same-conversation requests 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, with Markdown rendering, per-turn timestamps, turn-relative
  view (only turns up to the one you opened, with a jump-to-next link),
  click-any-turn-to-open-its-log navigation, and live auto-refresh (with an
  auto-follow toggle, matching the event stream's autoscroll pattern) that
  rebuilds the transcript in real time from the in-flight SSE chunk buffer
  while a request is still streaming
- New /dashboard/conversations page listing only conversations with 2+ turns
- Configurable auto-refresh intervals on both the timeline and conversations
  list pages

Also fixes a pre-existing bug where the timeline view never showed SSE/
stream-chunk events or respected email-masking, because RequestTimeline.tsx
hardcoded debugEnabled/emailsVisible instead of reading the same
server-side/store state RequestLoggerV2.tsx already used, and makes the
request detail panel and conversations list responsive on mobile.
2026-08-05 11:14:11 +02:00

127 lines
4.6 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([]), []);
});