diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 7d2c31c111..ddcfb1e0da 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -414,6 +414,7 @@ export async function handleChatCore({ skipUpstreamRetry = false, createPiiTransform = null, correlationId = null, + conversationId = null, modelPinned = false, }) { let { provider, model, extendedContext } = modelInfo; @@ -780,6 +781,7 @@ export async function handleChatCore({ providerRequest: initialProviderRequest, stage: "registered", correlationId, + sessionTag: conversationId || null, }) || generateRequestId(); // Initialize rate limit settings from persisted DB (once, lazy) @@ -905,7 +907,11 @@ export async function handleChatCore({ noLogEnabled, correlationId, modelPinned, - sessionTag: explicitSessionIdHeader, + // Resolved conversationId (open-sse/services/conversationTracker.ts) wins when + // present — it's populated for every request now, not just ones where the + // client explicitly sent x-omniroute-session-id. The raw header remains a + // fallback for any caller that somehow bypassed conversationId resolution. + sessionTag: conversationId || explicitSessionIdHeader, }); // Primary path: merge client model id + alias target so config on either key applies; resolved diff --git a/open-sse/services/conversationTracker.ts b/open-sse/services/conversationTracker.ts new file mode 100644 index 0000000000..9254031b8f --- /dev/null +++ b/open-sse/services/conversationTracker.ts @@ -0,0 +1,230 @@ +/** + * Conversation Tracker — assigns a stable conversation id across separate + * HTTP requests that are turns of the same multi-turn agentic conversation. + * + * Clients resend the full growing message/input history on every turn (no + * server-side state dependency), so continuation is detected by checking + * whether an earlier request's history is a strict prefix of the current + * request's history. This is a new, persisted mechanism — separate from + * `sessionManager.ts`'s `generateSessionId()` (in-memory, routing/latency + * only) even though it uses the same sha256-fingerprint style. + * + * @see Issue: X-ConversationId / agentic conversation tracking + */ + +import { createHash, randomUUID } from "node:crypto"; +import { + createAgenticConversation, + findAgenticConversationsByFingerprint, + touchOrCreateExternalConversation, + updateAgenticConversation, +} from "../../src/lib/db/agenticConversations.ts"; + +type JsonRecord = Record; + +interface CanonicalTurn { + role: "system" | "user" | "assistant" | "tool"; + text: string; +} + +export interface ResolveConversationIdInput { + body: JsonRecord | null | undefined; + model: string | null; + apiKeyId: string | null; + /** Raw `x-omniroute-session-id` header value, if the client supplied one. */ + clientSessionIdHeader: string | null; +} + +export interface ResolveConversationIdResult { + conversationId: string; + isNewConversation: boolean; +} + +// ── Canonicalization ───────────────────────────────────────────────────── + +function normalizeRole(raw: unknown): CanonicalTurn["role"] { + if (raw === "system" || raw === "user" || raw === "assistant" || raw === "tool") return raw; + if (raw === "developer") return "system"; + if (raw === "model") return "assistant"; + if (raw === "function") return "tool"; + return "user"; +} + +function stringifyContent(content: unknown): string { + if (typeof content === "string") return content; + if (content == null) return ""; + try { + return JSON.stringify(content); + } catch { + return ""; + } +} + +/** + * Flatten a Chat Completions `messages[]` array or a Responses API `input` + * (array, bare string, or single message-shaped object) into a stable, + * format-agnostic turn list. Ignores ids/tool_call_ids/metadata entirely — + * only role + a string projection of content survive, since those are the + * only fields that stay stable across a client's own re-encoding of history. + */ +export function extractCanonicalTurns(body: JsonRecord | null | undefined): CanonicalTurn[] { + if (!body || typeof body !== "object") return []; + + let raw: unknown[]; + if (Array.isArray(body.messages)) { + raw = body.messages; + } else if (Array.isArray(body.input)) { + raw = body.input; + } else if (typeof body.input === "string") { + raw = [{ role: "user", content: body.input }]; + } else if (body.input && typeof body.input === "object") { + raw = [body.input]; + } else { + raw = []; + } + + const turns: CanonicalTurn[] = []; + for (const item of raw) { + const rec = item && typeof item === "object" ? (item as JsonRecord) : {}; + // Responses API function_call/function_call_output items have no `role` + // but do carry stable identifying text — fold them in as "tool" turns so + // tool round-trips still contribute to the continuation signal. + const role = rec.role + ? normalizeRole(rec.role) + : rec.type === "function_call" || rec.type === "function_call_output" + ? "tool" + : null; + if (!role) continue; + const text = stringifyContent(rec.content ?? rec.text ?? rec.arguments ?? rec.output); + if (text) turns.push({ role, text }); + } + return turns; +} + +// ── Fingerprint (identity, O(1) regardless of history size) ───────────── + +function hashHex(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function hashShort(text: string): string { + return hashHex(text).slice(0, 16); +} + +function extractSystemOrFirstUserText(turns: CanonicalTurn[]): string | null { + const system = turns.find((t) => t.role === "system"); + if (system) return system.text; + const firstUser = turns.find((t) => t.role === "user"); + return firstUser ? firstUser.text : null; +} + +function extractToolNames(body: JsonRecord | null | undefined): string[] { + if (!body || !Array.isArray(body.tools)) return []; + const names: string[] = []; + for (const tool of body.tools as unknown[]) { + const rec = tool && typeof tool === "object" ? (tool as JsonRecord) : {}; + const fn = rec.function && typeof rec.function === "object" ? (rec.function as JsonRecord) : {}; + const name = typeof rec.name === "string" ? rec.name : typeof fn.name === "string" ? fn.name : ""; + if (name) names.push(name); + } + return names.sort(); +} + +export function computeFingerprintHash(input: { + apiKeyId: string | null; + model: string | null; + turns: CanonicalTurn[]; + toolNames: string[]; +}): string { + const firstText = extractSystemOrFirstUserText(input.turns); + const parts = [ + input.apiKeyId ?? "", + input.model ?? "", + firstText ? hashShort(firstText) : "", + input.toolNames.join(","), + ]; + // NOTE: no connectionId — conversation identity must not depend on which + // upstream connection this particular turn happened to be routed to. + return hashHex(parts.join("|")); +} + +// ── Bounded turn hash (continuation check, O(1) on very long histories) ── +// +// A history of 100k+ tokens (observed in real traffic) makes hashing the +// FULL turn array on every request expensive. Instead hash a bounded +// projection: total turn count, the role sequence (cheap - one char per +// turn), and the text of the first 2 and last 3 turns. This is a documented +// heuristic trade-off: two histories differing ONLY in an untouched middle +// section, with the same length, would collide. Accepted for the O(1) cost +// bound; see conversationTracker.test.ts for the explicit test pinning this +// known limitation. +const HEAD_TURNS = 2; +const TAIL_TURNS = 3; + +export function hashTurnsBounded(turns: CanonicalTurn[]): string { + const head = turns.slice(0, HEAD_TURNS); + const tail = turns.length > HEAD_TURNS ? turns.slice(-TAIL_TURNS) : []; + const roleSequence = turns.map((t) => t.role[0]).join(""); + const parts = [ + String(turns.length), + roleSequence, + ...head.map((t) => t.text), + ...tail.map((t) => t.text), + ]; + return hashHex(parts.join("")); +} + +// ── Orchestration ───────────────────────────────────────────────────────── + +const MAX_STORED_ID_LENGTH = 128; + +export async function resolveConversationId( + input: ResolveConversationIdInput +): Promise { + // Client override wins outright — deterministic, zero heuristic risk. + // Same header feature #8249 already reads (chatCore.ts); we don't invent a + // new prefix so the existing header's contract/format stays unchanged. + if (input.clientSessionIdHeader && input.clientSessionIdHeader.trim()) { + const id = input.clientSessionIdHeader.trim().slice(0, MAX_STORED_ID_LENGTH); + touchOrCreateExternalConversation(id, { apiKeyId: input.apiKeyId }); + return { conversationId: id, isNewConversation: false }; + } + + const turns = extractCanonicalTurns(input.body); + const toolNames = extractToolNames(input.body); + const fingerprintHash = computeFingerprintHash({ + apiKeyId: input.apiKeyId, + model: input.model, + turns, + toolNames, + }); + + const candidates = findAgenticConversationsByFingerprint(fingerprintHash); + for (const candidate of candidates) { + // A genuine continuation always strictly grows the history (the client + // appends at least the assistant's reply plus a new turn) — same-length + // must never match, or two independent single-shot requests with + // byte-identical content (a client retry, or two unrelated conversations + // that both just say "hi") would merge into one conversation. + if (turns.length <= candidate.lastMessageCount) continue; + const truncated = turns.slice(0, candidate.lastMessageCount); + if (hashTurnsBounded(truncated) === candidate.lastMessagesHash) { + updateAgenticConversation(candidate.id, { + lastMessageCount: turns.length, + lastMessagesHash: hashTurnsBounded(turns), + turnCount: candidate.turnCount + 1, + }); + return { conversationId: candidate.id, isNewConversation: false }; + } + } + + const id = `conv_${randomUUID()}`; + createAgenticConversation({ + id, + apiKeyId: input.apiKeyId, + fingerprintHash, + lastMessageCount: turns.length, + lastMessagesHash: hashTurnsBounded(turns), + }); + return { conversationId: id, isNewConversation: true }; +} diff --git a/src/app/(dashboard)/dashboard/conversations/page.tsx b/src/app/(dashboard)/dashboard/conversations/page.tsx new file mode 100644 index 0000000000..acb4954442 --- /dev/null +++ b/src/app/(dashboard)/dashboard/conversations/page.tsx @@ -0,0 +1,359 @@ +"use client"; + +import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { PROVIDER_COLORS, getHttpStatusStyle } from "@/shared/constants/colors"; +import { formatTime } from "@/shared/utils/formatting"; +import { copyToClipboard } from "@/shared/utils/clipboard"; +import RequestLoggerDetail from "@/shared/components/RequestLoggerDetail"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; + +interface ConversationRow { + id: string; + turnCount: number; + firstSeenAt: string; + lastSeenAt: string; + lastCallLogId: string | null; + lastModel: string | null; + lastProvider: string | null; + lastStatus: number | null; +} + +const DEFAULT_POLL_SECONDS = 5; +const POLL_STORAGE_KEY = "conversationsListPollSeconds"; + +function ProviderBadge({ provider }: { provider: string | null }) { + if (!provider) return ; + const style = (PROVIDER_COLORS as Record)[ + provider + ]; + if (!style) { + return ( + + {provider} + + ); + } + return ( + + {style.label} + + ); +} + +function StatusBadge({ status }: { status: number | null }) { + if (status == null) return ; + const style = getHttpStatusStyle(status); + return ( + + {status} + + ); +} + +function ConversationsPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + // Read once on mount, mirroring dashboard/logs/page.tsx (#6830/#8354): re-reading the + // live searchParams on every render re-fires the deep-link open effect right when the + // panel closes and router.replace() strips the ?id= param. + const [initialId] = useState(() => searchParams.get("id")); + + const [conversations, setConversations] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + + const { emailsVisible } = useEmailPrivacyStore(); + const [selectedLog, setSelectedLog] = useState(null); + const [detailData, setDetailData] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false); + const [pollSeconds, setPollSeconds] = useState(() => { + try { + const saved = localStorage.getItem(POLL_STORAGE_KEY); + const parsed = saved ? Number(saved) : DEFAULT_POLL_SECONDS; + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_POLL_SECONDS; + } catch { + return DEFAULT_POLL_SECONDS; + } + }); + const initialOpenedRef = useRef(false); + + useEffect(() => { + let cancelled = false; + + const load = () => { + if (document.visibilityState !== "visible") return; + fetch("/api/conversations?limit=100", { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (cancelled || !data) return; + setConversations(Array.isArray(data.conversations) ? data.conversations : []); + setTotal(typeof data.total === "number" ? data.total : 0); + }) + .catch(() => {}) + .finally(() => { + if (!cancelled) setLoading(false); + }); + }; + + load(); + const interval = setInterval(load, pollSeconds * 1000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [pollSeconds]); + + useEffect(() => { + fetch("/api/logs/detail?limit=1") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + setDetailLoggingEnabled(data.enabled === true); + }) + .catch(() => {}); + }, []); + + // Opens a request's detail panel in-place — used for the initial row click and for + // every subsequent turn/next-message navigation, so viewing a conversation never + // navigates away from this page (matches RequestLoggerV2/RequestTimeline). + const openById = useCallback( + async (id: string) => { + try { + const url = new URL(globalThis.location.href); + url.searchParams.set("id", id); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors + } + setDetailLoading(true); + try { + const res = await fetch(`/api/logs/${id}`, { cache: "no-store" }); + const data = res.ok ? await res.json() : null; + if (data) { + setSelectedLog({ + id: data.id ?? id, + timestamp: data.timestamp, + status: data.status ?? 0, + model: data.model ?? null, + provider: data.provider ?? null, + account: data.account ?? null, + duration: data.duration ?? 0, + tokens: data.tokens ?? { in: 0, out: 0 }, + active: data.active, + error: data.error ?? null, + path: data.path ?? null, + }); + setDetailData(data); + } + } catch { + // ignore fetch errors + } finally { + setDetailLoading(false); + } + }, + [router] + ); + + const closeDetail = useCallback(() => { + setSelectedLog(null); + setDetailData(null); + try { + const url = new URL(globalThis.location.href); + url.searchParams.delete("id"); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors + } + }, [router]); + + useEffect(() => { + if (!initialId || initialOpenedRef.current) return; + initialOpenedRef.current = true; + openById(initialId).catch(() => {}); + }, [initialId, openById]); + + const openConversation = (row: ConversationRow) => { + if (!row.lastCallLogId) return; + openById(row.lastCallLogId).catch(() => {}); + }; + + return ( +
+
+

Conversations

+
+ + {total} conversation{total === 1 ? "" : "s"} with 2+ turns + + +
+
+ + {loading && conversations.length === 0 && ( +
+ Loading conversations... +
+ )} + + {!loading && conversations.length === 0 && ( +
+ No multi-turn conversations yet. +
+ )} + + {conversations.length > 0 && ( + <> + {/* Mobile: stacked cards — avoids the horizontal-scroll table entirely on + narrow viewports instead of squeezing 6 columns into one row. */} +
+ {conversations.map((row) => ( +
openConversation(row)} + className="rounded-xl border border-border p-3 flex flex-col gap-2 active:bg-bg-subtle cursor-pointer" + > +
+ { + e.stopPropagation(); + copyToClipboard(row.id); + }} + className="font-mono text-[11px] text-text-main hover:underline truncate" + > + {row.id.slice(0, 16)}… + + + {row.turnCount} turns + +
+
+
+ + {row.lastModel ?? "—"} + + +
+ +
+
+ {formatTime(row.lastSeenAt)} +
+
+ ))} +
+ + {/* Desktop/tablet: full table */} +
+ + + + + + + + + + + + + {conversations.map((row) => ( + openConversation(row)} + > + + + + + + + + ))} + +
ConversationTurnsLast ModelProviderStatusLast Seen
+ { + e.stopPropagation(); + copyToClipboard(row.id); + }} + className="hover:underline" + > + {row.id.slice(0, 16)}… + + + {row.turnCount} + {row.lastModel ?? "—"} + + + + + {formatTime(row.lastSeenAt)} +
+
+ + )} + + {selectedLog && ( + + )} +
+ ); +} + +export default function ConversationsPage() { + return ( + + Loading conversations... + + } + > + + + ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx index 496cadd38a..d6c7f4084f 100644 --- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx @@ -4,10 +4,19 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import type { NormalizedTurn } from "@/mitm/inspector/types"; import { cn } from "@/shared/utils/cn"; +import { formatTime } from "@/shared/utils/formatting"; import { MessageContent } from "./MessageContent"; interface ChatBubbleProps { turn: NormalizedTurn; + /** Present only for multi-row conversation transcripts (see + * src/mitm/inspector/multiRowConversation.ts) — makes the bubble clickable, + * navigating to the request log that produced this turn. Absent for the + * single-request traffic-inspector usage. */ + onClick?: () => void; + /** True when this turn belongs to the request log currently open — shown + * highlighted instead of clickable (nowhere further to navigate to). */ + isCurrent?: boolean; } const ROLE_STYLES: Record = { @@ -24,27 +33,41 @@ const ROLE_LABEL_KEY: Record = { tool: "roleTool", }; -export function ChatBubble({ turn }: ChatBubbleProps) { +export function ChatBubble({ turn, onClick, isCurrent }: ChatBubbleProps) { const t = useTranslations("trafficInspector"); const [collapsed, setCollapsed] = useState(turn.role === "system"); const isSystem = turn.role === "system"; const isUser = turn.role === "user"; + const clickable = Boolean(onClick) && !isCurrent; return (
- {t(ROLE_LABEL_KEY[turn.role])} +
+ {t(ROLE_LABEL_KEY[turn.role])} + {turn.timestamp && ( + {formatTime(turn.timestamp)} + )} +
{isSystem && ( +
+
+ + {/* Same setting Timeline's "Lane reuse" control persists under (shared key) — + changing it here also changes when Timeline treats a lane as reusable. */} + +
+
+ {open && ( +
+ {earlierTurnsOmitted && ( +
+ Earlier turns not shown for this long-running conversation. +
+ )} + {liveTurns.map((turn, i) => { + const isCurrent = turn.sourceCallLogId === currentLogId; + return ( + onNavigateToLog(turn.sourceCallLogId) + : undefined + } + /> + ); + })} + {nextId && ( + + )} + {polling && ( +
+ {/* Same ring-spinner markup as the "in progress" status badge above + (not the shared icon glyph, which spins visibly off-axis). */} + + watching for new turns… +
+ )} +
+ )} + + ); +} + // ─── Stream section + Detail Modal ─────────────────────────────────────────────────────────── function StreamSection({ title, json, onCopy }) { @@ -195,6 +477,7 @@ export default function RequestLoggerDetail({ onNext, relatedLogs = [], onSelectRelated, + onNavigateToLog, }) { // Close on Escape key useEffect(() => { @@ -348,7 +631,7 @@ export default function RequestLoggerDetail({ const codexAccountRotation = getCodexAccountRotation(detail); return (
e.stopPropagation()} > {/* Modal Header */} -
-
+
+
{log.active ? ( @@ -408,7 +691,7 @@ export default function RequestLoggerDetail({ )}
-
+
-
+
{/* Metadata Grid */} {log.active ? (
@@ -840,6 +1123,19 @@ export default function RequestLoggerDetail({
) : ( <> + {Array.isArray(detail?.conversationTurns) && detail.conversationTurns.length > 0 && ( + + )} + {streamChunks && streamChunks.provider && ( )} + {visibleColumns.conversation && ( + {t("columns.conversation")} + )} @@ -1563,6 +1567,15 @@ const RequestLoggerV2 = forwardRef )} + {visibleColumns.conversation && ( + + {log.sessionTag ? ( + {log.sessionTag.slice(0, 12)}… + ) : ( + + )} + + )} ); })} @@ -1616,6 +1629,7 @@ const RequestLoggerV2 = forwardRef openDetail({ id })} /> )}
diff --git a/src/shared/components/RequestTimeline.tsx b/src/shared/components/RequestTimeline.tsx index 8f46fece5b..165ca291cc 100644 --- a/src/shared/components/RequestTimeline.tsx +++ b/src/shared/components/RequestTimeline.tsx @@ -5,8 +5,9 @@ import { useRouter } from "next/navigation"; import { getHttpStatusStyle } from "@/shared/constants/colors"; import { copyToClipboard } from "@/shared/utils/clipboard"; import RequestLoggerDetail from "@/shared/components/RequestLoggerDetail"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; -interface TimelineLog { +export interface TimelineLog { id: string; timestamp: string; status: number; @@ -19,6 +20,8 @@ interface TimelineLog { completed?: boolean; error?: string | null; path?: string | null; + /** Conversation id (X-ConversationId) — same field as call_logs.session_tag. */ + sessionTag?: string | null; } interface Lane { @@ -35,7 +38,8 @@ const LANE_HEIGHT = BAR_HEIGHT + LANE_GAP; const HEADER_HEIGHT = 48; const AXIS_HEIGHT = 32; const MIN_BAR_WIDTH = 3; -const POLL_INTERVAL_MS = 2000; +const DEFAULT_LIST_POLL_SECONDS = 2; +const TIMELINE_LIST_POLL_STORAGE_KEY = "timelineListPollSeconds"; const FOLLOW_LINE_X = 0.75; const LIVE_LINE_FRACTION = 0.9; @@ -74,8 +78,28 @@ function getStatusColor(status: number, active: boolean | undefined): string { return getHttpStatusStyle(status).bg; } -function allocateLanes(items: TimelineLog[], nowMs: number): Map { +const DEFAULT_CONVERSATION_LANE_REUSE_WINDOW_MS = 2 * 60 * 1000; + +// Exported so other components (e.g. the "Full Conversation" transcript panel +// in RequestLoggerDetail.tsx) can decide "is this conversation still in +// progress" using the SAME setting as the timeline's lane-reuse window, +// rather than a separate, potentially-inconsistent one. +export const CONVERSATION_LANE_REUSE_STORAGE_KEY = "timelineConversationLaneReuseMinutes"; + +/** + * Assigns each item a lane (row) index. Items sharing a `sessionTag` + * (conversation id) are forced onto the same lane as long as the gap since + * that lane's last item is within `reuseWindowMs` — after that, the lane is + * free again and falls back to the normal greedy overlap-avoidance packing + * below (unrelated to any conversation). + */ +export function allocateLanes( + items: TimelineLog[], + nowMs: number, + reuseWindowMs: number = DEFAULT_CONVERSATION_LANE_REUSE_WINDOW_MS +): Map { const lanes: Lane[] = []; + const laneConversation: (string | null)[] = []; const laneMap = new Map(); const sorted = [...items].sort((a, b) => { @@ -86,18 +110,35 @@ function allocateLanes(items: TimelineLog[], nowMs: number): Map for (const item of sorted) { const { startMs, endMs } = computeBarRange(item, nowMs); + const conversationId = item.sessionTag || null; let placed = false; - for (let i = 0; i < lanes.length; i++) { - if (lanes[i].endMs < startMs) { - lanes[i] = { startMs, endMs }; - laneMap.set(item.id, i); - placed = true; - break; + + if (conversationId) { + for (let i = 0; i < lanes.length; i++) { + if (laneConversation[i] === conversationId && startMs - lanes[i].endMs <= reuseWindowMs) { + lanes[i] = { startMs, endMs }; + laneMap.set(item.id, i); + placed = true; + break; + } + } + } + + if (!placed) { + for (let i = 0; i < lanes.length; i++) { + if (lanes[i].endMs < startMs) { + lanes[i] = { startMs, endMs }; + laneConversation[i] = conversationId; + laneMap.set(item.id, i); + placed = true; + break; + } } } if (!placed) { laneMap.set(item.id, lanes.length); + laneConversation.push(conversationId); lanes.push({ startMs, endMs }); } } @@ -150,9 +191,31 @@ export default function RequestTimeline({ const [isDragging, setIsDragging] = useState(false); const [dragStartX, setDragStartX] = useState(0); const [dragStartOffset, setDragStartOffset] = useState(0); + const { emailsVisible } = useEmailPrivacyStore(); const [selectedLog, setSelectedLog] = useState(null); const [detailData, setDetailData] = useState(null); const [detailLoading, setDetailLoading] = useState(false); + const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false); + const [conversationLaneReuseMinutes, setConversationLaneReuseMinutes] = useState(() => { + if (globalThis.window === undefined) return 2; + try { + const saved = localStorage.getItem(CONVERSATION_LANE_REUSE_STORAGE_KEY); + const parsed = saved ? Number(saved) : 2; + return Number.isFinite(parsed) && parsed > 0 ? parsed : 2; + } catch { + return 2; + } + }); + const [listPollSeconds, setListPollSeconds] = useState(() => { + if (globalThis.window === undefined) return DEFAULT_LIST_POLL_SECONDS; + try { + const saved = localStorage.getItem(TIMELINE_LIST_POLL_STORAGE_KEY); + const parsed = saved ? Number(saved) : DEFAULT_LIST_POLL_SECONDS; + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIST_POLL_SECONDS; + } catch { + return DEFAULT_LIST_POLL_SECONDS; + } + }); const canvasRef = useRef(null); const animRef = useRef(0); // Guards the ?id= deep-link mount effect below. Also armed by any manual @@ -162,6 +225,16 @@ export default function RequestTimeline({ // reopen the modal right after the user closed it. const initialOpenedRef = useRef(false); + useEffect(() => { + fetch("/api/logs/detail?limit=1") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + setDetailLoggingEnabled(data.enabled === true); + }) + .catch(() => {}); + }, []); + useEffect(() => { let cancelled = false; fetch("/api/usage/call-logs?limit=200") @@ -179,12 +252,12 @@ export default function RequestTimeline({ .then((res) => (res.ok ? res.json() : [])) .then((data) => setLogs(data)) .catch(() => {}); - }, POLL_INTERVAL_MS); + }, listPollSeconds * 1000); return () => { cancelled = true; clearInterval(id); }; - }, []); + }, [listPollSeconds]); useEffect(() => { if (!canvasRef.current) return undefined; @@ -250,7 +323,10 @@ export default function RequestTimeline({ }); }, [logs, timeStart, timeEnd, nowMs]); - const laneMap = useMemo(() => allocateLanes(logs, nowMs), [logs, nowMs]); + const laneMap = useMemo( + () => allocateLanes(logs, nowMs, conversationLaneReuseMinutes * 60 * 1000), + [logs, nowMs, conversationLaneReuseMinutes] + ); const maxLane = useMemo(() => (laneMap.size > 0 ? Math.max(...laneMap.values()) : 0), [laneMap]); const barElements = useMemo(() => { @@ -271,6 +347,39 @@ export default function RequestTimeline({ }); }, [visibleLogs, timeStart, timeEnd, nowMs, laneMap, canvasWidth]); + // One connector per consecutive pair of bars sharing a conversation id AND + // lane (i.e. allocateLanes actually treated them as one continuous + // conversation, not two bars that just happen to be adjacent). + const connectorElements = useMemo(() => { + const byConversation = new Map(); + for (const el of barElements) { + const cid = el.log.sessionTag; + if (!cid) continue; + const list = byConversation.get(cid); + if (list) list.push(el); + else byConversation.set(cid, [el]); + } + + const connectors: { id: string; x1: number; x2: number; y: number }[] = []; + for (const els of byConversation.values()) { + const sorted = [...els].sort( + (a, b) => new Date(a.log.timestamp).getTime() - new Date(b.log.timestamp).getTime() + ); + for (let i = 0; i < sorted.length - 1; i++) { + const a = sorted[i]; + const b = sorted[i + 1]; + if (a.topPx !== b.topPx) continue; // different lanes — reuse window lapsed + connectors.push({ + id: `${a.log.id}-${b.log.id}`, + x1: a.leftPct + a.widthPct, + x2: b.leftPct, + y: a.topPx + BAR_HEIGHT / 2, + }); + } + } + return connectors; + }, [barElements]); + const axisTicks = useMemo(() => { const totalMs = timeEnd - timeStart; if (totalMs <= 0) return []; @@ -373,6 +482,13 @@ export default function RequestTimeline({ // Deep-link support: open the request from ?id= on mount without waiting for // it to show up in the polled `logs` list (mirrors RequestLoggerV2's openDetail). const openById = useCallback(async (id: string) => { + try { + const url = new URL(globalThis.location.href); + url.searchParams.set("id", id); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors + } setDetailLoading(true); try { const res = await fetch(`/api/logs/${id}`, { cache: "no-store" }); @@ -398,7 +514,7 @@ export default function RequestTimeline({ } finally { setDetailLoading(false); } - }, []); + }, [router]); useEffect(() => { if (!initialSelectedId || initialOpenedRef.current) return; @@ -574,6 +690,51 @@ export default function RequestTimeline({ > Reset + {/* Conversation lane-reuse window: how long a lane stays reserved + for its conversation before falling back to normal packing. */} + + {/* How often the timeline re-polls /api/usage/call-logs for new rows. */} + {/* Zoom */}
))} + + {/* Conversation connectors — one arrow per consecutive same-conversation + bar pair sharing a lane. */} + + + + + + + {connectorElements.map(({ id, x1, x2, y }) => ( + + ))} +
{/* NOW line — full height of the canvas, outside content div */} @@ -823,14 +1020,15 @@ export default function RequestTimeline({ log={selectedLog as any} detail={detailData} loading={detailLoading} - debugEnabled={false} - emailsVisible={false} + debugEnabled={selectedLog?.active ? true : detailLoggingEnabled} + emailsVisible={emailsVisible} onClose={closeDetail} onCopy={copyToClipboard} onPrevious={undefined} onNext={undefined} relatedLogs={[]} onSelectRelated={undefined} + onNavigateToLog={openById} /> )}
diff --git a/src/shared/constants/sidebarVisibility/sections.ts b/src/shared/constants/sidebarVisibility/sections.ts index 213f765168..b6633fca81 100644 --- a/src/shared/constants/sidebarVisibility/sections.ts +++ b/src/shared/constants/sidebarVisibility/sections.ts @@ -401,6 +401,13 @@ const LOGS_GROUP: SidebarItemGroup = { subtitleKey: "logsTimelineSubtitle", icon: "view_timeline", }, + { + id: "conversations", + href: "/dashboard/conversations", + i18nKey: "conversations", + subtitleKey: "conversationsSubtitle", + icon: "forum", + }, ], }; diff --git a/src/shared/constants/sidebarVisibility/types.ts b/src/shared/constants/sidebarVisibility/types.ts index 104f57b29e..def6d53bea 100644 --- a/src/shared/constants/sidebarVisibility/types.ts +++ b/src/shared/constants/sidebarVisibility/types.ts @@ -54,6 +54,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "logs-proxy", "logs-console", "logs-timeline", + "conversations", "logs-activity", "health", "runtime", diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 510314f455..021cce87a7 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -73,7 +73,9 @@ import { withSessionHeader, withSelectedConnectionHeader, withCorrelationId, + withConversationId, } from "./chatHelpers"; +import { resolveConversationId } from "@omniroute/open-sse/services/conversationTracker.ts"; import { isAntigravityMissingProjectError, PROVIDER_BREAKER_FAILURE_STATUSES, @@ -529,6 +531,18 @@ export async function handleChat( })); telemetry.endPhase(); + // Agentic conversation tracking (X-ConversationId): resolved once per + // incoming HTTP request, before combo dispatch / credential retries, so + // every attempt for this request shares the same id and the + // agentic_conversations row is only touched once. + const clientConversationHeader = request.headers.get("x-omniroute-session-id")?.trim() || null; + const { conversationId } = await resolveConversationId({ + body: body as Record, + model: modelStr, + apiKeyId: apiKeyInfo?.id ?? null, + clientSessionIdHeader: clientConversationHeader, + }); + // T08: per-key active session limit (0 = unlimited). if (apiKeyInfo?.id && sessionId) { const maxSessions = @@ -818,6 +832,7 @@ export async function handleChat( cachedSettings: settings, providerId: target?.providerId ?? null, correlationId: reqId, + conversationId, modelPinned: (target as any)?.modelPinned ?? false, reasoningDecision, reasoningIntent, @@ -883,6 +898,7 @@ export async function handleChat( sessionAffinityKey, emergencyFallbackTried: true, forceLiveComboTest: isComboLiveTest, + conversationId, }, combo.strategy, true @@ -890,7 +906,7 @@ export async function handleChat( if (fallbackResponse.ok) { log.info("GLOBAL_FALLBACK", `Global fallback ${fallbackModel} succeeded`); recordTelemetry(telemetry); - return withSessionHeader(fallbackResponse, sessionId); + return withConversationId(withSessionHeader(fallbackResponse, sessionId), conversationId); } log.warn( "GLOBAL_FALLBACK", @@ -922,12 +938,13 @@ export async function handleChat( apiKeyId: apiKeyInfo?.id ?? null, apiKeyName: apiKeyInfo?.name ?? null, correlationId: reqId, + sessionTag: conversationId, startTime: telemetry?.startTime, requestBody: clientRawRequest?.body ?? null, }); } catch {} } - return withCorrelationId(withSessionHeader(response, sessionId), reqId); + return withConversationId(withCorrelationId(withSessionHeader(response, sessionId), reqId), conversationId); } telemetry.endPhase(); @@ -960,6 +977,7 @@ export async function handleChat( forceLiveComboTest: isComboLiveTest, forcedConnectionId: requestedConnectionId, correlationId: reqId, + conversationId, routingComboId, reasoningDecision, reasoningIntent, @@ -969,7 +987,7 @@ export async function handleChat( false ); recordTelemetry(telemetry); - return withCorrelationId(withSessionHeader(response, sessionId), reqId); + return withConversationId(withCorrelationId(withSessionHeader(response, sessionId), reqId), conversationId); } // The clientRawRequest envelope lives in ./chat/clientRawRequest.ts. Imported for local use @@ -1007,6 +1025,7 @@ async function handleSingleModelChat( cachedSettings?: any; providerId?: string | null; correlationId?: string | null; + conversationId?: string | null; routingComboId?: string | null; modelPinned?: boolean; reasoningDecision?: ReasoningRuleDecision | null; @@ -1079,6 +1098,7 @@ async function handleSingleModelChat( allowRateLimitedConnection: target?.allowRateLimitedConnection === true, providerId: target?.providerId ?? null, correlationId: runtimeOptions?.correlationId ?? null, + conversationId: runtimeOptions?.conversationId ?? null, // #7360 follow-up — see the primary handleSingleModel closure above. modelAbortSignal: target?.modelAbortSignal ?? null, }, @@ -1174,6 +1194,7 @@ async function handleSingleModelChat( apiKeyId: apiKeyInfo?.id ?? null, apiKeyName: apiKeyInfo?.name ?? null, correlationId: runtimeOptions?.correlationId ?? null, + sessionTag: runtimeOptions?.conversationId ?? null, startTime: telemetry?.startTime, }); } catch {} @@ -1451,6 +1472,7 @@ async function handleSingleModelChat( cachedSettings: runtimeOptions.cachedSettings, skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false, correlationId: runtimeOptions?.correlationId ?? null, + conversationId: runtimeOptions?.conversationId ?? null, modelPinned: runtimeOptions?.modelPinned ?? false, routingComboId: runtimeOptions?.routingComboId ?? null, }); diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 7faf961975..789775aaa2 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -394,6 +394,7 @@ export async function executeChatWithBreaker({ skipUpstreamRetry = false, trafficType = "production", correlationId = null, + conversationId = null, modelPinned = false, routingComboId = null, }: ExecuteChatWithBreakerOptions): Promise<{ result: any; tlsFingerprintUsed: boolean }> { @@ -432,6 +433,7 @@ export async function executeChatWithBreaker({ skipUpstreamRetry, trafficType: normalizedTrafficType, correlationId, + conversationId, modelPinned, routingComboId, onCredentialsRefreshed: async (newCreds: any) => { @@ -872,6 +874,23 @@ export function withCorrelationId(response: Response, correlationId: string | nu } } +export function withConversationId(response: Response, conversationId: string | null): Response { + if (!response || !conversationId) return response; + + try { + response.headers.set("X-ConversationId", conversationId); + return response; + } catch { + const cloned = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + cloned.headers.set("X-ConversationId", conversationId); + return cloned; + } +} + export function withSelectedConnectionHeader( response: Response, connectionId: string | null | undefined diff --git a/src/sse/handlers/rejectedRequestUsage.ts b/src/sse/handlers/rejectedRequestUsage.ts index 46b8099014..8a817393de 100644 --- a/src/sse/handlers/rejectedRequestUsage.ts +++ b/src/sse/handlers/rejectedRequestUsage.ts @@ -30,6 +30,8 @@ export interface RejectedRequestUsageInput { comboStepId?: string | null; comboExecutionKey?: string | null; correlationId?: string | null; + /** Conversation id (X-ConversationId) — see open-sse/services/conversationTracker.ts. */ + sessionTag?: string | null; apiKeyId?: string | null; apiKeyName?: string | null; connectionId?: string | null; @@ -56,6 +58,7 @@ export async function recordRejectedRequestUsage(input: RejectedRequestUsageInpu comboStepId = null, comboExecutionKey = null, correlationId = null, + sessionTag = null, apiKeyId = null, apiKeyName = null, connectionId = undefined, @@ -86,6 +89,7 @@ export async function recordRejectedRequestUsage(input: RejectedRequestUsageInpu apiKeyId, apiKeyName, correlationId, + sessionTag, }).catch(() => {}); // 2. usage_history — so the per-api-key usage counter reflects rejected diff --git a/tests/unit/agenticConversations.test.ts b/tests/unit/agenticConversations.test.ts new file mode 100644 index 0000000000..54fbbe1f00 --- /dev/null +++ b/tests/unit/agenticConversations.test.ts @@ -0,0 +1,188 @@ +/** + * Unit tests for src/lib/db/agenticConversations.ts CRUD. + */ + +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-agentic-conv-db-")); +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "agentic-conversations-test-secret"; + +import { + createAgenticConversation, + findAgenticConversationsByFingerprint, + updateAgenticConversation, + touchOrCreateExternalConversation, + getLatestCallLogForConversation, + getAllCallLogsForConversation, + listMultiTurnConversations, +} from "../../src/lib/db/agenticConversations.ts"; +import { getDbInstance } from "../../src/lib/db/core.ts"; + +test("createAgenticConversation + findAgenticConversationsByFingerprint round-trip", () => { + const row = createAgenticConversation({ + apiKeyId: "key-a", + fingerprintHash: "fp-round-trip", + lastMessageCount: 1, + lastMessagesHash: "hash-1", + }); + + assert.match(row.id, /^conv_/); + assert.equal(row.turnCount, 1); + + const found = findAgenticConversationsByFingerprint("fp-round-trip"); + assert.equal(found.length, 1); + assert.equal(found[0].id, row.id); + assert.equal(found[0].apiKeyId, "key-a"); +}); + +test("findAgenticConversationsByFingerprint returns multiple rows for a shared fingerprint", () => { + createAgenticConversation({ + apiKeyId: "key-b", + fingerprintHash: "fp-shared", + lastMessageCount: 1, + lastMessagesHash: "hash-b1", + }); + createAgenticConversation({ + apiKeyId: "key-b", + fingerprintHash: "fp-shared", + lastMessageCount: 1, + lastMessagesHash: "hash-b2", + }); + + const found = findAgenticConversationsByFingerprint("fp-shared"); + assert.equal(found.length, 2); +}); + +test("updateAgenticConversation updates message count/hash/turn count", () => { + const row = createAgenticConversation({ + apiKeyId: "key-c", + fingerprintHash: "fp-update", + lastMessageCount: 1, + lastMessagesHash: "hash-c1", + }); + + updateAgenticConversation(row.id, { + lastMessageCount: 5, + lastMessagesHash: "hash-c5", + turnCount: 3, + }); + + const found = findAgenticConversationsByFingerprint("fp-update"); + assert.equal(found[0].lastMessageCount, 5); + assert.equal(found[0].lastMessagesHash, "hash-c5"); + assert.equal(found[0].turnCount, 3); +}); + +test("touchOrCreateExternalConversation creates then increments turn_count on repeat calls", () => { + const id = "ext-conv-test-id"; + touchOrCreateExternalConversation(id, { apiKeyId: "key-d" }); + + const db = getDbInstance(); + const afterCreate = db + .prepare("SELECT turn_count FROM agentic_conversations WHERE id = ?") + .get(id) as { turn_count: number }; + assert.equal(afterCreate.turn_count, 1); + + touchOrCreateExternalConversation(id, { apiKeyId: "key-d" }); + const afterTouch = db + .prepare("SELECT turn_count FROM agentic_conversations WHERE id = ?") + .get(id) as { turn_count: number }; + assert.equal(afterTouch.turn_count, 2); +}); + +test("getLatestCallLogForConversation returns the most recent row with an exact session_tag match", () => { + const db = getDbInstance(); + const conversationId = "conv-for-latest-lookup"; + + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, session_tag) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?)` + ).run("call-older", "2026-01-01T00:00:00.000Z", conversationId); + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, session_tag) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?)` + ).run("call-newer", "2026-01-01T00:05:00.000Z", conversationId); + // A prefix-matching but NOT exact tag must never match (guards against + // accidentally reusing a LIKE-based filter for this exact-match lookup). + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, session_tag) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?)` + ).run("call-prefix-decoy", "2026-01-01T00:10:00.000Z", conversationId + "-extra"); + + const latest = getLatestCallLogForConversation(conversationId); + assert.equal(latest?.id, "call-newer"); +}); + +test("getAllCallLogsForConversation returns every row ascending, exact-match only", () => { + const db = getDbInstance(); + const conversationId = "conv-for-all-rows"; + + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, session_tag) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?)` + ).run("all-newer", "2026-02-01T00:05:00.000Z", conversationId); + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, session_tag) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?)` + ).run("all-older", "2026-02-01T00:00:00.000Z", conversationId); + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, session_tag) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?)` + ).run("all-decoy", "2026-02-01T00:03:00.000Z", conversationId + "-extra"); + + const rows = getAllCallLogsForConversation(conversationId); + assert.deepEqual( + rows.map((r) => r.id), + ["all-older", "all-newer"] + ); +}); + +test("listMultiTurnConversations only returns conversations with turn_count >= 2, joined to their latest call_logs row", () => { + const db = getDbInstance(); + + createAgenticConversation({ + id: "conv-single-turn", + apiKeyId: null, + fingerprintHash: "fp-single", + lastMessageCount: 1, + lastMessagesHash: "h1", + }); + + const multi = createAgenticConversation({ + id: "conv-multi-turn", + apiKeyId: null, + fingerprintHash: "fp-multi", + lastMessageCount: 3, + lastMessagesHash: "h2", + }); + updateAgenticConversation(multi.id, { + lastMessageCount: 3, + lastMessagesHash: "h2", + turnCount: 2, + }); + + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, session_tag) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', 'opencode-zen', ?)` + ).run("multi-turn-1", "2026-03-01T00:00:00.000Z", "conv-multi-turn"); + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, session_tag) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'gemma-4', 'gemini', ?)` + ).run("multi-turn-2", "2026-03-01T00:01:00.000Z", "conv-multi-turn"); + + const { rows, total } = listMultiTurnConversations(); + const ids = rows.map((r) => r.id); + assert.ok(ids.includes("conv-multi-turn")); + assert.ok(!ids.includes("conv-single-turn")); + assert.ok(total >= 1); + + const found = rows.find((r) => r.id === "conv-multi-turn"); + assert.equal(found?.lastCallLogId, "multi-turn-2"); + assert.equal(found?.lastModel, "gemma-4"); + assert.equal(found?.lastProvider, "gemini"); +}); diff --git a/tests/unit/conversationTracker.test.ts b/tests/unit/conversationTracker.test.ts new file mode 100644 index 0000000000..f54c6fc809 --- /dev/null +++ b/tests/unit/conversationTracker.test.ts @@ -0,0 +1,285 @@ +/** + * 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, + hashTurnsBounded, + resolveConversationId, +} from "../../open-sse/services/conversationTracker.ts"; +import { + findAgenticConversationsByFingerprint, +} from "../../src/lib/db/agenticConversations.ts"; + +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"); + assert.equal(turns[1].role, "tool"); + assert.equal(turns[2].role, "tool"); +}); + +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 turns = extractCanonicalTurns({ messages: [{ role: "user", content: "hi" }] }); + const a = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", turns, toolNames: [] }); + const b = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", turns, toolNames: [] }); + assert.equal(a, b); +}); + +test("computeFingerprintHash: different apiKeyId or model changes the hash", () => { + const turns = extractCanonicalTurns({ messages: [{ role: "user", content: "hi" }] }); + const base = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", turns, toolNames: [] }); + const diffKey = computeFingerprintHash({ + apiKeyId: "key2", + model: "gpt-4o", + turns, + toolNames: [], + }); + const diffModel = computeFingerprintHash({ + apiKeyId: "key1", + model: "gpt-5", + turns, + toolNames: [], + }); + assert.notEqual(base, diffKey); + assert.notEqual(base, diffModel); +}); + +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, + }); + 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, + }); + 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, + }); + + // 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, + }); + + assert.equal(turn3.conversationId, turn1.conversationId); +}); + +test("resolveConversationId: divergent history mints a new id despite a shared fingerprint", async () => { + const apiKeyId = "key-divergent"; + + // Establish a real 2-turn conversation: turn1 (1 msg) then turn2 (3 msgs, + // extending turn1's history) — this brings the stored candidate up to + // lastMessageCount=3 with a real, specific history hash. + const turn1 = await resolveConversationId({ + body: { model: "big-pickle", messages: [{ role: "user", content: "same first message" }] }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + }); + const turn2 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "same first message" }, + { role: "assistant", content: "real reply" }, + { role: "user", content: "real follow-up" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + }); + assert.equal(turn2.conversationId, turn1.conversationId); + + // Same fingerprint inputs (model/apiKeyId/first message/no tools) and the + // SAME message count as the established candidate, but the actual content + // beyond the shared first message never matches turn2's real history — + // must NOT be merged into that conversation despite the length/fingerprint + // match, proving the prefix-hash check (not just length/fingerprint) gates + // identity. + const divergent = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "same first message" }, + { role: "assistant", content: "a totally different reply than what actually happened" }, + { role: "user", content: "a follow-up that never occurred in the real history" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + }); + + assert.notEqual(divergent.conversationId, turn1.conversationId); + assert.equal(divergent.isNewConversation, true); + + // Both conversations really do share one fingerprint bucket — proves the + // prefix check, not just the fingerprint, is what kept them separate. + const turns = extractCanonicalTurns({ messages: [{ role: "user", content: "same first message" }] }); + const fingerprint = computeFingerprintHash({ apiKeyId, model: "big-pickle", turns, toolNames: [] }); + const candidates = findAgenticConversationsByFingerprint(fingerprint); + assert.ok(candidates.length >= 2); +}); + +test("resolveConversationId: two independent single-message requests must NOT merge, even with byte-identical content", async () => { + // Regression: a client retrying a failed request (or two genuinely separate + // conversations opening with the same line, e.g. "hi") both arrive as a + // single-message request. Before this fix, a same-length request whose + // truncated-to-candidate-length hash trivially matched (since it IS the + // candidate, verbatim) was accepted as a "continuation" — merging two + // unrelated single-shot requests under one conversation id. A real + // continuation always strictly grows the history (assistant reply + more), + // so same-length must never match. + const apiKeyId = "key-identical-singleshot"; + const body = { model: "big-pickle", messages: [{ role: "user", content: "hi" }] }; + + const first = await resolveConversationId({ + body, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + }); + const second = await resolveConversationId({ + body, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + }); + const third = await resolveConversationId({ + body, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + }); + + assert.notEqual(second.conversationId, first.conversationId); + assert.notEqual(third.conversationId, first.conversationId); + assert.notEqual(third.conversationId, second.conversationId); + assert.equal(first.isNewConversation, true); + assert.equal(second.isNewConversation, true); + assert.equal(third.isNewConversation, true); +}); + +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, + }); + 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, + }); + assert.equal(second.conversationId, headerValue); +}); + +test("hashTurnsBounded: documented blind spot — a genuinely untouched middle turn is invisible", () => { + // hashTurnsBounded only inspects: total length, the role sequence, the + // first 2 turns, and the last 3 turns. With 8 turns, index 3 falls in + // neither the head (0-1) nor the tail (5-7) — changing ONLY that turn's + // text, with everything else (including the role sequence) identical, + // must produce an identical bounded hash. This pins the accepted + // trade-off explicitly rather than relying on it silently. + const build = (middleText: string) => [ + { role: "user" as const, text: "start-1" }, + { role: "assistant" as const, text: "start-2" }, + { role: "assistant" as const, text: "unchanged-a" }, + { role: "assistant" as const, text: middleText }, + { role: "assistant" as const, text: "unchanged-b" }, + { role: "assistant" as const, text: "tail-1" }, + { role: "assistant" as const, text: "tail-2" }, + { role: "assistant" as const, text: "tail-3" }, + ]; + assert.equal( + hashTurnsBounded(build("middle-A")), + hashTurnsBounded(build("middle-B-completely-different")) + ); +}); diff --git a/tests/unit/multiRowConversation.test.ts b/tests/unit/multiRowConversation.test.ts new file mode 100644 index 0000000000..3f25ba902c --- /dev/null +++ b/tests/unit/multiRowConversation.test.ts @@ -0,0 +1,126 @@ +/** + * 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([]), []); +}); diff --git a/tests/unit/request-timeline-lane-allocation.test.ts b/tests/unit/request-timeline-lane-allocation.test.ts new file mode 100644 index 0000000000..c079eb918e --- /dev/null +++ b/tests/unit/request-timeline-lane-allocation.test.ts @@ -0,0 +1,76 @@ +/** + * Unit tests for RequestTimeline's allocateLanes conversation-aware lane + * reuse (agentic conversation tracking / X-ConversationId). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { allocateLanes, type TimelineLog } from "../../src/shared/components/RequestTimeline.tsx"; + +function log( + id: string, + timestampMs: number, + durationMs: number, + sessionTag: string | null = null +): TimelineLog { + return { + id, + timestamp: new Date(timestampMs).toISOString(), + status: 200, + model: "test-model", + provider: "test-provider", + account: null, + duration: durationMs, + tokens: { in: 0, out: 0 }, + completed: true, + sessionTag, + }; +} + +const BASE = 1_800_000_000_000; // arbitrary fixed epoch ms + +test("allocateLanes: unrelated non-overlapping bars share a lane as before (no regression)", () => { + const items = [log("a", BASE, 1000), log("b", BASE + 5000, 1000)]; + const lanes = allocateLanes(items, BASE + 10_000); + assert.equal(lanes.get("a"), lanes.get("b")); +}); + +test("allocateLanes: same conversation id reuses the same lane within the reuse window", () => { + const items = [ + log("a", BASE, 1000, "conv-1"), + // Overlapping in time with "a" would normally force a different lane — + // but sharing conv-1 within the reuse window should force it onto a's lane. + log("b", BASE + 500, 1000, "conv-1"), + ]; + const lanes = allocateLanes(items, BASE + 10_000, 2 * 60 * 1000); + assert.equal(lanes.get("a"), lanes.get("b")); +}); + +test("allocateLanes: same conversation id falls back to normal packing outside the reuse window", () => { + const reuseWindowMs = 2 * 60 * 1000; + const items = [ + log("a", BASE, 1000, "conv-2"), + // Same conversation id, but arrives long after the reuse window lapsed — + // must NOT be forced onto a's lane if that lane is still busy with + // something else (falls back to the ordinary overlap-avoidance packer). + log("b", BASE + reuseWindowMs + 60_000, 1000, "conv-2"), + // Occupies a's lane again right after "a" finishes, before "b" arrives — + // forces "b" to pack elsewhere via the normal greedy logic. + log("c", BASE + 2000, 1000, null), + ]; + const lanes = allocateLanes(items, BASE + reuseWindowMs + 65_000, reuseWindowMs); + // "a" and "c" share a's lane (c starts after a ends); "b" arrives far later + // and long after the reuse window, so it is free to reuse that same lane + // once it's genuinely free again — the key assertion is that "b" was NOT + // force-placed via conversation reuse logic (which only applies within the + // window), i.e. this is ordinary greedy packing, not identity-based. + assert.equal(lanes.get("a"), lanes.get("c")); + assert.ok(lanes.get("b") !== undefined); +}); + +test("allocateLanes: different conversation ids never share a lane just for overlapping in time", () => { + const items = [log("a", BASE, 5000, "conv-x"), log("b", BASE + 1000, 5000, "conv-y")]; + const lanes = allocateLanes(items, BASE + 10_000); + assert.notEqual(lanes.get("a"), lanes.get("b")); +});