refactor(dashboard): simplify request detail panel, fix Responses API tool-call gap

RequestLoggerDetail's Conversation Context section now renders only the
currently-viewed request's own buildRequestTurns/buildResponseTurns output
directly, instead of reconstructing a cross-row transcript from prior
requests sharing a session_tag. A single request's own body already is its
full context; the operator asked for this after the multi-row reconstruction
made indentation grow unboundedly (superseded by conversationTracker.ts's
no-forking redesign). Deletes multiRowConversation.ts and its test — dead
code once the panel no longer walks prior rows. Kept live-streaming updates
for an active request (extractPartialAssistantText now also accumulates
delta.reasoning_content, so the panel keeps visibly progressing during a
reasoning-only streaming phase) and added a liveRefresh toggle + scroll-to-
bottom control mirroring StreamSection's existing pattern.

Fixes a real, universal data-loss bug found while investigating why tool
calls looked different between the detail panel and the conversation tree
view: turnsFromOpenAiMessages (conversationNormalizer.ts) only handled
role-based Chat Completions messages. Real Responses API traffic (OpenClaw)
sends bare {type:"function_call"}/{type:"function_call_output"}/
{type:"reasoning"} items with NO role field at all, so they were silently
dropped — every tool call in a Responses API conversation vanished from the
Conversation Context panel. Now handled explicitly before the role-based
branches.

Also fixes a Dark Reader (browser extension) false-positive hydration
warning on OmniRouteLogo's SVG lines (suppressHydrationWarning — the
extension injects data-darkreader-inline-stroke before React hydrates), and
adds break-words to MarkdownMessage so long unspaced runs (raw JSON, ids)
wrap instead of overflowing a narrower container like the conversation
modal. ChatBubble's onClick doc comment updated to reflect it's no longer
multiRowConversation-specific.
This commit is contained in:
Markus Hartung
2026-08-06 06:00:32 +02:00
parent a2df6cf289
commit 9384391daf
10 changed files with 345 additions and 824 deletions

View File

@@ -148,7 +148,11 @@ export default function MarkdownMessage({ content, className }: MarkdownMessageP
};
return (
<div className={className}>
// break-words: long unspaced runs (raw JSON, ids, tokens) have no natural
// wrap point, so without it they overflow their container instead of
// wrapping — invisible in a wide full-page layout, glaring in a narrower
// one (e.g. the conversation tree modal).
<div className={`break-words ${className ?? ""}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>

View File

@@ -9,12 +9,11 @@ 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. */
/** Optional — makes the bubble clickable when a caller has somewhere to
* navigate to for this turn (e.g. a tree/list view linking back to the
* request that produced it). */
onClick?: () => void;
/** True when this turn belongs to the request log currently open — shown
/** True when this turn belongs to the request currently open — shown
* highlighted instead of clickable (nowhere further to navigate to). */
isCurrent?: boolean;
}

View File

@@ -2,76 +2,6 @@ import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getCallLogById } from "@/lib/usageDb";
import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory";
import { getAllCallLogsForConversation } from "@/lib/db/agenticConversations";
import { buildMultiRowConversation, type LoadedCallLogRow } from "@/mitm/inspector/multiRowConversation";
// Reconstructing N rows means N sequential getCallLogById disk reads — bound
// how many earlier turns get loaded so a very long-running agent session
// doesn't add unbounded latency to opening any one of its turns.
const MAX_LOADED_ROWS = 50;
interface ConversationAttachment {
conversationTurns: unknown[];
conversationNextId: string | null;
conversationIsLatest: boolean;
conversationLastSeenAt: string | null;
conversationEarlierTurnsOmitted: boolean;
}
/**
* "Full Conversation" panel data: every call_logs row sharing this entry's
* conversation id (session_tag), reconstructed into one chronological,
* per-turn-tagged transcript truncated to the turns visible as of the
* CURRENTLY viewed row (turn-relative view) — not always "the latest turn".
*/
async function buildConversationAttachment(
sessionTag: string | null | undefined,
currentEntry: any
): Promise<ConversationAttachment | null> {
if (!sessionTag) return null;
try {
const allRefs = getAllCallLogsForConversation(sessionTag);
if (allRefs.length === 0) return null;
const currentIndex = allRefs.findIndex((r) => r.id === String(currentEntry.id));
const found = currentIndex !== -1;
// Defensive: the current row should always appear in its own conversation's
// row list. If it somehow doesn't, show everything with no "next" link
// and no "in progress" auto-refresh rather than crash or guess wrong.
let refsToLoad = found ? allRefs.slice(0, currentIndex + 1) : allRefs;
const earlierTurnsOmitted = refsToLoad.length > MAX_LOADED_ROWS;
if (earlierTurnsOmitted) refsToLoad = refsToLoad.slice(-MAX_LOADED_ROWS);
const loadedRows: LoadedCallLogRow[] = [];
for (const ref of refsToLoad) {
const entry =
ref.id === String(currentEntry.id) ? currentEntry : await getCallLogById(ref.id);
if (!entry) continue;
loadedRows.push({
id: String(entry.id),
timestamp: String(entry.timestamp ?? ref.timestamp),
requestBody: entry.requestBody ?? null,
responseBody: entry.responseBody ?? null,
});
}
const conversationTurns = buildMultiRowConversation(loadedRows);
const nextRef = found ? (allRefs[currentIndex + 1] ?? null) : null;
const lastRef = allRefs[allRefs.length - 1] ?? null;
return {
conversationTurns,
conversationNextId: nextRef?.id ?? null,
conversationIsLatest: found && currentIndex === allRefs.length - 1,
conversationLastSeenAt: lastRef?.timestamp ?? null,
conversationEarlierTurnsOmitted: earlierTurnsOmitted,
};
} catch (e) {
console.warn("/api/logs/[id] - failed to build conversation transcript:", e);
return null;
}
}
// Best-effort parse of the accumulated SSE `data:` lines captured live for an
// in-flight request (open-sse/utils/requestLogger.ts's appendConvertedChunk
@@ -84,6 +14,7 @@ function extractPartialAssistantText(
for (const chunkArr of [streamChunks.client, streamChunks.provider, streamChunks.openai]) {
if (!Array.isArray(chunkArr) || chunkArr.length === 0) continue;
let text = "";
let reasoning = "";
for (const raw of chunkArr) {
for (const line of String(raw).split("\n")) {
const idx = line.indexOf("data:");
@@ -94,72 +25,24 @@ function extractPartialAssistantText(
const parsed = JSON.parse(jsonStr);
const delta = parsed?.choices?.[0]?.delta ?? parsed?.choices?.[0]?.message;
if (typeof delta?.content === "string") text += delta.content;
if (typeof delta?.reasoning_content === "string") reasoning += delta.reasoning_content;
} catch {
// partial/malformed chunk line (e.g. cut mid-write) — skip it
}
}
}
if (text) return text;
// Reasoning-model providers (e.g. DeepSeek-R1-style) stream
// `reasoning_content` before any visible `content` — with only the
// content check above, the live panel had nothing new to show for the
// whole reasoning phase and looked frozen even while the SSE event
// stream kept visibly ticking. Surface the reasoning text meanwhile so
// the panel keeps progressing.
if (reasoning) return `_Thinking…_\n\n${reasoning}`;
}
return "";
}
/**
* Same idea as buildConversationAttachment, but for a request that hasn't
* finished (and isn't in call_logs yet): prior turns come from already-
* persisted rows, and the currently-streaming reply is reconstructed from the
* live streamChunks capture — so the "Full Conversation" panel can grow in
* real time while a request is still generating, matching the raw SSE panel.
*/
async function buildInFlightConversationAttachment(pendingRequestDetail: {
id: string;
sessionTag?: string | null;
clientRequest?: unknown;
streamChunks?: { provider?: string[]; openai?: string[]; client?: string[] } | null;
}): Promise<ConversationAttachment | null> {
const sessionTag = pendingRequestDetail.sessionTag;
if (!sessionTag) return null;
try {
const allRefs = getAllCallLogsForConversation(sessionTag);
const earlierTurnsOmitted = allRefs.length > MAX_LOADED_ROWS;
const refsToLoad = earlierTurnsOmitted ? allRefs.slice(-MAX_LOADED_ROWS) : allRefs;
const loadedRows: LoadedCallLogRow[] = [];
for (const ref of refsToLoad) {
const entry = await getCallLogById(ref.id);
if (!entry) continue;
loadedRows.push({
id: String(entry.id),
timestamp: String(entry.timestamp ?? ref.timestamp),
requestBody: entry.requestBody ?? null,
responseBody: entry.responseBody ?? null,
});
}
const partialText = extractPartialAssistantText(pendingRequestDetail.streamChunks);
const nowIso = new Date().toISOString();
loadedRows.push({
id: pendingRequestDetail.id,
timestamp: nowIso,
requestBody: pendingRequestDetail.clientRequest ?? null,
responseBody: partialText
? { choices: [{ message: { role: "assistant", content: partialText } }] }
: null,
});
return {
conversationTurns: buildMultiRowConversation(loadedRows),
conversationNextId: null,
conversationIsLatest: true,
conversationLastSeenAt: nowIso,
conversationEarlierTurnsOmitted: earlierTurnsOmitted,
};
} catch (e) {
console.warn("/api/logs/[id] - failed to build in-flight conversation transcript:", e);
return null;
}
}
export const dynamic = "force-dynamic";
export async function GET(
@@ -200,18 +83,12 @@ export async function GET(
active: true,
pipelinePayloads,
hasPipelineDetails: true,
// The still-generating reply so far — the request's own context
// panel renders this alongside its (already-complete) requestBody
// instead of waiting for the stream to finish.
partialAssistantText: extractPartialAssistantText(pendingRequestDetail.streamChunks),
};
const inFlightConversationAttachment = await buildInFlightConversationAttachment({
id: pendingRequestDetail.id,
sessionTag: (pendingRequestDetail as any).sessionTag ?? null,
clientRequest: pendingRequestDetail.clientRequest,
streamChunks: pendingRequestDetail.streamChunks,
});
if (inFlightConversationAttachment) {
Object.assign(activeEntry, inFlightConversationAttachment);
}
return NextResponse.json(activeEntry);
}
} catch (e) {
@@ -270,14 +147,6 @@ export async function GET(
if (!persistedRequest) return NextResponse.json({ error: "Not found" }, { status: 404 });
const conversationAttachment = await buildConversationAttachment(
(persistedRequest as any).sessionTag,
persistedRequest
);
if (conversationAttachment) {
Object.assign(persistedRequest, conversationAttachment);
}
return NextResponse.json(persistedRequest);
} catch (err) {
console.error("[API ERROR] /api/logs/[id] failed:", err);

View File

@@ -79,8 +79,7 @@ function blocksFromOpenAiContent(content: unknown): NormalizedBlock[] {
} else if (type === "tool_result") {
out.push({
type: "tool_result",
tool_use_id:
typeof block.tool_use_id === "string" ? block.tool_use_id : "",
tool_use_id: typeof block.tool_use_id === "string" ? block.tool_use_id : "",
content: block.content ?? null,
});
} else if (typeof block.text === "string") {
@@ -94,10 +93,7 @@ function blocksFromOpenAiContent(content: unknown): NormalizedBlock[] {
* OpenAI assistant messages may declare `tool_calls`. Each becomes a
* `tool_use` block alongside any text content.
*/
function appendOpenAiToolCalls(
blocks: NormalizedBlock[],
toolCalls: unknown
): NormalizedBlock[] {
function appendOpenAiToolCalls(blocks: NormalizedBlock[], toolCalls: unknown): NormalizedBlock[] {
if (!Array.isArray(toolCalls)) return blocks;
for (const raw of toolCalls) {
const tc = asRecord(raw);
@@ -126,11 +122,74 @@ function appendOpenAiToolCalls(
/**
* Build NormalizedTurn[] from OpenAI / Anthropic chat messages.
*/
/** Responses API reasoning items carry `summary: [{type: "summary_text", text}]`. */
function reasoningSummaryText(summary: unknown): string {
if (!Array.isArray(summary)) return "";
const parts: string[] = [];
for (const raw of summary) {
const block = asRecord(raw);
if (block && typeof block.text === "string") parts.push(block.text);
}
return parts.join("\n\n");
}
function turnsFromOpenAiMessages(messages: unknown[]): NormalizedTurn[] {
const out: NormalizedTurn[] = [];
for (const raw of messages) {
const msg = asRecord(raw);
if (!msg) continue;
// Responses API items for tool activity/reasoning carry no `role` at
// all — they're distinguished by `type` instead. Handle these before the
// role-based branches below, which would otherwise silently drop them
// (empty `content`, no `tool_calls`, `normalizeRole(undefined)` defaults
// to "user") — the exact gap that made a real OpenClaw request's
// function_call/function_call_output items vanish from the Conversation
// Context panel entirely (2026-08-06).
if (msg.type === "function_call") {
let parsedInput: unknown = {};
if (typeof msg.arguments === "string") {
try {
parsedInput = JSON.parse(msg.arguments);
} catch {
parsedInput = msg.arguments;
}
} else if (msg.arguments != null) {
parsedInput = msg.arguments;
}
out.push({
role: "assistant",
blocks: [
{
type: "tool_use",
id: typeof msg.call_id === "string" ? msg.call_id : "",
name: typeof msg.name === "string" ? msg.name : "",
input: parsedInput,
},
],
});
continue;
}
if (msg.type === "function_call_output") {
out.push({
role: "tool",
blocks: [
{
type: "tool_result",
tool_use_id: typeof msg.call_id === "string" ? msg.call_id : "",
content: msg.output ?? null,
},
],
});
continue;
}
if (msg.type === "reasoning") {
const text = reasoningSummaryText(msg.summary);
if (!text) continue;
out.push({ role: "assistant", blocks: [{ type: "text", text }] });
continue;
}
const role = normalizeRole(msg.role);
if (msg.role === "tool" || msg.role === "function") {
@@ -374,9 +433,7 @@ export function buildResponseTurns(req: InterceptedRequest): NormalizedTurn[] {
* Normalize an intercepted LLM request + response into a provider-agnostic
* conversation. Returns `null` for non-LLM requests or unparseable payloads.
*/
export function normalizeConversation(
req: InterceptedRequest
): NormalizedConversation | null {
export function normalizeConversation(req: InterceptedRequest): NormalizedConversation | null {
if (req.detectedKind !== "llm") return null;
const requestBody = tryParseJson(req.requestBody);

View File

@@ -1,142 +0,0 @@
/**
* Multi-row conversation transcript builder.
*
* The single-request `normalizeConversation()` (conversationNormalizer.ts)
* builds a transcript from ONE request+response pair — fine for the
* traffic-inspector's per-request view, but a multi-turn agentic
* conversation is actually N separate call_logs rows (one per HTTP request),
* each carrying its own real timestamp. This module reconstructs a single,
* chronological turn list across all of them, tagging every turn with the
* call_logs row (id + timestamp) that actually produced it — needed for
* per-turn timestamps and click-to-navigate-to-that-turn's-log.
*
* Relies on the invariant enforced by
* open-sse/services/conversationTracker.ts::resolveConversationId: rows
* sharing a conversation id have STRICTLY increasing request-turn counts
* (a real continuation always appends at least the assistant's reply + a new
* turn). The delta between consecutive rows' turn counts is therefore always
* >= 0 by construction; the `Math.max(0, ...)` clamp below is a defensive
* backstop, not load-bearing for well-formed data.
*/
import { buildRequestTurns, buildResponseTurns } from "./conversationNormalizer.ts";
import type { InterceptedRequest, NormalizedTurn } from "./types.ts";
export interface ConversationTurn extends NormalizedTurn {
sourceCallLogId: string;
timestamp: string;
}
export interface LoadedCallLogRow {
id: string;
timestamp: string;
requestBody: unknown;
responseBody: unknown;
}
/**
* open-sse/handlers/chatCore/logTruncation.ts::truncateForLog() replaces a
* request body over ~8KB with a bare summary — {_truncated, _originalBytes,
* messageCount, ...} — dropping `messages`/`input` entirely to bound
* in-memory logging cost. Any real conversation with substantial history
* hits this on nearly every row, so buildRequestTurns() legitimately returns
* zero turns for it: there is nothing left to parse. Without this check the
* transcript would silently render only the response for that row (looking
* exactly like "just the last line" of a long chain), and — worse — every
* SUBSEQUENT row's delta slicing would be computed against the wrong
* previousTotal (0 instead of the row's real turn count), corrupting the
* rest of the reconstruction too.
*
* `knownCount` is null when the summary carries no count at all — either
* older data logged before truncateForLog() learned to count Responses API
* `input[]` bodies, or some other body shape it doesn't recognize. In that
* case we can't safely diff against previousTotal, so the caller falls back
* to a single generic placeholder instead of a specific "N messages" one.
*/
function getTruncationInfo(body: unknown): { knownCount: number | null } | null {
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
const record = body as Record<string, unknown>;
if (record._truncated !== true) return null;
return { knownCount: typeof record.messageCount === "number" ? record.messageCount : null };
}
function placeholderTurn(text: string, row: LoadedCallLogRow): ConversationTurn {
return {
role: "system",
blocks: [{ type: "text", text }],
sourceCallLogId: row.id,
timestamp: row.timestamp,
};
}
function rowAsInterceptedRequest(row: LoadedCallLogRow): InterceptedRequest {
return {
id: row.id,
source: "custom-host",
timestamp: row.timestamp,
method: "POST",
host: "",
path: "",
requestHeaders: {},
requestBody: row.requestBody != null ? JSON.stringify(row.requestBody) : null,
requestSize: 0,
responseHeaders: {},
responseBody: row.responseBody != null ? JSON.stringify(row.responseBody) : null,
responseSize: 0,
status: 0,
detectedKind: "llm",
};
}
/**
* Build the full chronological turn list across every call_logs row of one
* conversation. `rows` must already be sorted ascending by timestamp.
*/
export function buildMultiRowConversation(rows: LoadedCallLogRow[]): ConversationTurn[] {
let previousTotal = 0;
const turns: ConversationTurn[] = [];
for (const row of rows) {
const truncation = getTruncationInfo(row.requestBody);
const respTurns = buildResponseTurns(rowAsInterceptedRequest(row));
if (truncation === null) {
const reqTurns = buildRequestTurns(row.requestBody) ?? [];
const sliceStart = Math.max(0, Math.min(previousTotal, reqTurns.length));
for (const turn of reqTurns.slice(sliceStart)) {
turns.push({ ...turn, sourceCallLogId: row.id, timestamp: row.timestamp });
}
previousTotal = reqTurns.length + respTurns.length;
} else if (truncation.knownCount !== null) {
const effectiveReqTurnCount = truncation.knownCount;
const sliceStart = Math.max(0, Math.min(previousTotal, effectiveReqTurnCount));
const newCount = Math.max(0, effectiveReqTurnCount - sliceStart);
if (newCount > 0) {
turns.push(
placeholderTurn(
`${newCount} message${newCount === 1 ? "" : "s"} not shown — the request body was too large to log.`,
row
)
);
}
previousTotal = effectiveReqTurnCount + respTurns.length;
} else {
// Count unknown (older data, or a body shape truncateForLog() doesn't
// recognize) — can't tell how many of this row's turns are genuinely
// new, so surface one generic placeholder rather than silently
// showing nothing. previousTotal is left as-is: we have no reliable
// new figure to add to it, and understating a later row's "new" count
// is a safer failure mode here than overstating it.
turns.push(
placeholderTurn("Earlier messages not shown — the request body was too large to log.", row)
);
previousTotal = previousTotal + respTurns.length;
}
for (const turn of respTurns) {
turns.push({ ...turn, sourceCallLogId: row.id, timestamp: row.timestamp });
}
}
return turns;
}

View File

@@ -1,23 +1,19 @@
import { z } from "zod";
export type CaptureSource =
| "agent-bridge"
| "custom-host"
| "http-proxy"
| "system-proxy"
| "tproxy";
"agent-bridge" | "custom-host" | "http-proxy" | "system-proxy" | "tproxy";
export type DetectedKind = "llm" | "app" | "unknown";
export interface InterceptedRequest {
id: string; // uuid
id: string; // uuid
source: CaptureSource;
agent?: import("../types").AgentId; // only when source === "agent-bridge"
timestamp: string; // ISO 8601
agent?: import("../types").AgentId; // only when source === "agent-bridge"
timestamp: string; // ISO 8601
method: string;
host: string;
path: string;
requestHeaders: Record<string, string>;
requestBody: string | null; // masked
requestBody: string | null; // masked
requestSize: number;
responseHeaders: Record<string, string>;
responseBody: string | null;
@@ -26,16 +22,16 @@ export interface InterceptedRequest {
proxyLatencyMs?: number;
upstreamLatencyMs?: number;
totalLatencyMs?: number;
error?: string; // sanitized
error?: string; // sanitized
sourceModel?: string | null;
mappedModel?: string | null;
detectedKind?: DetectedKind;
contextKey?: string; // 12-hex SHA-256 of system prompt
contextKey?: string; // 12-hex SHA-256 of system prompt
annotation?: string;
sessionId?: string;
note?: string;
pid?: number; // originating process id (Linux only)
processName?: string; // originating process name (Linux only)
pid?: number; // originating process id (Linux only)
processName?: string; // originating process name (Linux only)
}
export const InterceptedRequestSchema = z.object({
@@ -76,9 +72,9 @@ export type NormalizedBlock =
export interface NormalizedTurn {
role: "system" | "user" | "assistant" | "tool";
blocks: NormalizedBlock[];
/** call_logs.id that produced this turn — set only by the multi-row
* conversation transcript builder (src/mitm/inspector/multiRowConversation.ts),
* absent for the single-request traffic-inspector ConversationTab usage. */
/** call_logs.id that produced this turn, when a caller has one to attach
* (e.g. linking a turn back to its source request) — absent for a plain
* single-request normalization. */
sourceCallLogId?: string;
/** ISO timestamp of the call_logs row that produced this turn — same
* scoping as sourceCallLogId. */

View File

@@ -7,6 +7,13 @@ type OmniRouteLogoProps = {
className?: string;
};
// Dark Reader (and similar browser extensions) injects style/
// data-darkreader-inline-stroke attributes onto elements with an inline
// `stroke` before React hydrates, causing a harmless but noisy
// hydration-mismatch warning on the <line> elements below — not an app bug,
// see https://nextjs.org/docs/messages/react-hydration-error's own "browser
// extension" case.
export default function OmniRouteLogo({ size = 20, className = "" }: OmniRouteLogoProps) {
return (
<svg
@@ -28,6 +35,7 @@ export default function OmniRouteLogo({ size = 20, className = "" }: OmniRouteLo
<circle cx="16" cy="27" r="1.5" fill="currentColor" />
{/* Connection lines */}
<line
suppressHydrationWarning
x1="16"
y1="13"
x2="8"
@@ -37,6 +45,7 @@ export default function OmniRouteLogo({ size = 20, className = "" }: OmniRouteLo
strokeLinecap="round"
/>
<line
suppressHydrationWarning
x1="16"
y1="13"
x2="24"
@@ -46,6 +55,7 @@ export default function OmniRouteLogo({ size = 20, className = "" }: OmniRouteLo
strokeLinecap="round"
/>
<line
suppressHydrationWarning
x1="16"
y1="19"
x2="8"
@@ -55,6 +65,7 @@ export default function OmniRouteLogo({ size = 20, className = "" }: OmniRouteLo
strokeLinecap="round"
/>
<line
suppressHydrationWarning
x1="16"
y1="19"
x2="24"
@@ -64,6 +75,7 @@ export default function OmniRouteLogo({ size = 20, className = "" }: OmniRouteLo
strokeLinecap="round"
/>
<line
suppressHydrationWarning
x1="16"
y1="13"
x2="16"
@@ -73,6 +85,7 @@ export default function OmniRouteLogo({ size = 20, className = "" }: OmniRouteLo
strokeLinecap="round"
/>
<line
suppressHydrationWarning
x1="16"
y1="19"
x2="16"

View File

@@ -9,10 +9,8 @@ import {
import { formatDuration, formatApiKeyLabel, maskAccount } from "@/shared/utils/formatting";
import { formatErrorForDisplay } from "@/shared/utils/formatting";
import { ChatBubble } from "@/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble";
// Same key RequestTimeline.tsx persists its lane-reuse-window setting under —
// deliberately reused (not a separate setting) so "is this conversation still
// in progress" means the same thing everywhere in the dashboard.
import { CONVERSATION_LANE_REUSE_STORAGE_KEY } from "@/shared/components/RequestTimeline";
import { buildRequestTurns, buildResponseTurns } from "@/mitm/inspector/conversationNormalizer";
import type { InterceptedRequest, NormalizedTurn } from "@/mitm/inspector/types";
// ─── Payload Code Block ─────────────────────────────────────────────────────
@@ -67,281 +65,181 @@ function PayloadSection({ title, json, onCopy, collapsible = true, defaultOpen =
);
}
// ─── Full Conversation transcript section ───────────────────────────────────
// Renders the multi-turn chat transcript for this request's conversation
// (see open-sse/services/conversationTracker.ts and
// src/mitm/inspector/multiRowConversation.ts). The API route
// (src/app/api/logs/[id]/route.ts) already reconstructs the turn-relative
// transcript (turns up to and including the currently-viewed request) tagged
// with each turn's own source call_logs id + timestamp, so this component
// only needs to render + wire up navigation/auto-refresh — no normalization
// happens here.
// Waiting for a brand new row/turn to appear (nothing streaming right now).
const CONVERSATION_POLL_INTERVAL_MS = 4000;
// The currently-viewed row itself is actively streaming (detail.active===true)
// — poll fast so the live turn's text visibly grows, matching the raw SSE panel.
// ─── Conversation context section ───────────────────────────────────────────
// Renders THIS request's own context (its request body's messages/input, plus
// its response) — a plain single-request normalization, same shape as the
// traffic-inspector's ConversationTab, no cross-request reconstruction. While
// the request is still generating (detail.active === true) the response side
// shows the partial text captured so far, refreshed on a short poll scoped to
// just this section.
const CONVERSATION_ACTIVE_POLL_INTERVAL_MS = 1200;
const DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES = 2;
// Set right before navigating via "View next message" so the freshly-mounted
// section (a new request's detail remounts this component via key={log.id})
// knows to scroll itself into view instead of leaving the reader at the top
// of the modal — sessionStorage survives the remount without needing this
// threaded as a prop through every host page (RequestLoggerV2/RequestTimeline/
// the conversations list panel).
const CONVERSATION_SCROLL_FLAG_KEY = "conversationScrollToPanelOnMount";
// Same naming convention as StreamSection's "pref:stream:autoscroll".
const CONVERSATION_AUTO_FOLLOW_STORAGE_KEY = "pref:conversation:autoFollow";
function getConversationReuseWindowMs() {
try {
const saved = localStorage.getItem(CONVERSATION_LANE_REUSE_STORAGE_KEY);
const minutes = saved ? Number(saved) : DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES;
if (Number.isFinite(minutes) && minutes > 0) return minutes * 60 * 1000;
} catch {
// localStorage unavailable — fall through to default
}
return DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES * 60 * 1000;
function asInterceptedResponseBody(responseBody: unknown): InterceptedRequest {
return {
id: "",
source: "custom-host",
timestamp: "",
method: "POST",
host: "",
path: "",
requestHeaders: {},
requestBody: null,
requestSize: 0,
responseHeaders: {},
responseBody: responseBody != null ? JSON.stringify(responseBody) : null,
responseSize: 0,
status: 0,
detectedKind: "llm",
};
}
function ConversationTranscriptSection({
turns,
nextId,
isLatest,
lastSeenAt,
earlierTurnsOmitted,
currentLogId,
onNavigateToLog,
}) {
function ConversationContextSection({ log, detail }) {
const [open, setOpen] = useState(true);
const [polling, setPolling] = useState(false);
const [liveTurns, setLiveTurns] = useState(turns);
const [autoFollow, setAutoFollow] = useState(() => {
const [liveDetail, setLiveDetail] = useState(detail);
const [liveRefresh, setLiveRefresh] = useState(() => {
try {
const v = localStorage.getItem(CONVERSATION_AUTO_FOLLOW_STORAGE_KEY);
const v = localStorage.getItem("pref:conversationContext:liveRefresh");
return v == null ? true : v === "1";
} catch {
return true;
}
});
const [reuseMinutes, setReuseMinutes] = useState(() => {
try {
const saved = localStorage.getItem(CONVERSATION_LANE_REUSE_STORAGE_KEY);
const n = saved ? Number(saved) : DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES;
} catch {
return DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES;
}
});
const sectionRef = useRef<HTMLDivElement>(null);
const turnsBoxRef = useRef<HTMLDivElement>(null);
// Keep the box scrolled to the newest turn as liveTurns grows — same
// scroll-on-content-change idea as StreamSection's autoscroll, applied to
// the turn list instead of the raw chunk text.
useEffect(() => {
if (!open) return;
setLiveDetail(detail);
}, [detail]);
// Same live-poll pattern as the SSE Events section (StreamSection below),
// but gated on liveRefresh too: an active request keeps generating either
// way, this toggle only controls whether THIS panel keeps fetching/
// redrawing while the user reads it.
useEffect(() => {
if (!liveDetail?.active || !liveRefresh) return;
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const tick = () => {
if (cancelled) return;
if (document.visibilityState !== "visible") {
timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
return;
}
fetch(`/api/logs/${log.id}`, { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (cancelled || !data) return;
setLiveDetail(data);
if (data.active) timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
})
.catch(() => {
timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
});
};
timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}, [liveDetail?.active, liveRefresh, log.id]);
const toggleLiveRefresh = () => {
const next = !liveRefresh;
setLiveRefresh(next);
try {
localStorage.setItem("pref:conversationContext:liveRefresh", next ? "1" : "0");
} catch {}
};
const scrollToBottom = () => {
const el = turnsBoxRef.current;
if (!el) return;
requestAnimationFrame(() => {
try {
el.scrollTop = el.scrollHeight;
} catch {
// ignore — best-effort UX only
}
} catch {}
});
}, [liveTurns, open]);
const toggleAutoFollow = () => {
const next = !autoFollow;
setAutoFollow(next);
try {
localStorage.setItem(CONVERSATION_AUTO_FOLLOW_STORAGE_KEY, next ? "1" : "0");
} catch {
// localStorage unavailable — the toggle still works for this session
}
};
const requestBody =
liveDetail?.requestBody ?? liveDetail?.pipelinePayloads?.clientRequest ?? null;
const requestTurns = buildRequestTurns(requestBody) ?? [];
const responseBody = liveDetail?.responseBody ?? null;
const responseTurns: NormalizedTurn[] =
responseBody != null
? buildResponseTurns(asInterceptedResponseBody(responseBody))
: liveDetail?.partialAssistantText
? [
{
role: "assistant",
blocks: [{ type: "text", text: liveDetail.partialAssistantText }],
},
]
: [];
const allTurns: NormalizedTurn[] = [...requestTurns, ...responseTurns];
// Follow new content as it streams in — same idea as StreamSection's
// autoscroll effect, tied to the same liveRefresh toggle.
useEffect(() => {
let shouldScroll = false;
try {
shouldScroll = sessionStorage.getItem(CONVERSATION_SCROLL_FLAG_KEY) === "1";
if (shouldScroll) sessionStorage.removeItem(CONVERSATION_SCROLL_FLAG_KEY);
} catch {
// sessionStorage unavailable — skip the scroll-into-view convenience
}
if (shouldScroll) sectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}, []);
if (!liveRefresh || !open) return;
scrollToBottom();
}, [allTurns.length, liveDetail?.partialAssistantText, liveRefresh, open]);
useEffect(() => {
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const scheduleNext = (delayMs: number) => {
if (cancelled) return;
timeoutId = setTimeout(tick, delayMs);
};
// "Is this conversation still in progress" depends on Date.now(), an
// impure read — it must not happen directly during render (useMemo) or as
// a synchronous setState call at the top of this effect. Deferring the
// first evaluation into a callback (same shape as the ticks that follow
// it) mirrors the pattern RequestTimeline.tsx already uses for its own
// Date.now()-based nowMs state (set inside a requestAnimationFrame
// callback, never synchronously in the effect body).
// Self-rescheduling (setTimeout, not setInterval) so the delay can shrink
// to CONVERSATION_ACTIVE_POLL_INTERVAL_MS while the currently-viewed
// request is itself streaming, and fall back to the slower interval once
// it's just waiting for a new row to appear.
const tick = () => {
if (cancelled) return;
if (!isLatest || !lastSeenAt) {
setPolling(false);
return;
}
const reuseWindowMs = getConversationReuseWindowMs();
const withinWindow = Date.now() - new Date(lastSeenAt).getTime() < reuseWindowMs;
setPolling(withinWindow);
if (!withinWindow) return;
if (document.visibilityState !== "visible") {
scheduleNext(CONVERSATION_POLL_INTERVAL_MS);
return;
}
fetch(`/api/logs/${currentLogId}`, { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (cancelled || !data) return;
if (Array.isArray(data.conversationTurns)) setLiveTurns(data.conversationTurns);
if (data.conversationNextId && autoFollow) {
onNavigateToLog(data.conversationNextId);
return; // this section is about to unmount (key={log.id} remount)
}
scheduleNext(
data.active ? CONVERSATION_ACTIVE_POLL_INTERVAL_MS : CONVERSATION_POLL_INTERVAL_MS
);
})
.catch(() => {
scheduleNext(CONVERSATION_POLL_INTERVAL_MS);
});
};
timeoutId = setTimeout(tick, 0);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}, [isLatest, lastSeenAt, currentLogId, onNavigateToLog, autoFollow]);
if (allTurns.length === 0) return null;
return (
<div ref={sectionRef}>
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<div>
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-3">
<h3 className="text-[11px] text-text-muted uppercase tracking-wider font-bold">
Full Conversation
Conversation Context
</h3>
<button
onClick={() => setOpen((v) => !v)}
className="p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
aria-label={open ? "Collapse Full Conversation" : "Expand Full Conversation"}
aria-label={open ? "Collapse Conversation Context" : "Expand Conversation Context"}
>
<span className="material-symbols-outlined text-[16px]">
{open ? "expand_less" : "expand_more"}
</span>
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={toggleAutoFollow}
title={
autoFollow
? "Auto-follow: on (jumps to the next turn as soon as it lands)"
: "Auto-follow: off"
}
className={`p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors ${autoFollow ? "text-primary" : ""}`}
aria-pressed={autoFollow}
aria-label="Toggle auto-follow to next turn"
>
<span className="material-symbols-outlined text-[18px]">vertical_align_bottom</span>
</button>
{/* Same setting Timeline's "Lane reuse" control persists under (shared key) —
changing it here also changes when Timeline treats a lane as reusable. */}
<label
className="flex items-center gap-1 px-2 py-1 text-[10px] text-text-muted bg-bg-subtle rounded-md border border-border"
title="How long after the last turn this conversation is still considered 'in progress' and auto-refreshed."
>
<span>Auto-refresh</span>
<input
type="number"
min={1}
step={1}
value={reuseMinutes}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const next = Math.max(1, Number(e.target.value) || 1);
setReuseMinutes(next);
try {
localStorage.setItem(CONVERSATION_LANE_REUSE_STORAGE_KEY, String(next));
} catch {
// localStorage unavailable — the input still works for this session
}
}}
className="w-8 bg-transparent text-center font-mono focus:outline-none"
/>
<span>min</span>
</label>
</div>
{open && (
<div className="flex items-center gap-1">
{liveDetail?.active && (
<button
onClick={toggleLiveRefresh}
title={liveRefresh ? "Live refresh: on" : "Live refresh: off"}
className={`p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors ${liveRefresh ? "text-primary" : ""}`}
aria-pressed={liveRefresh}
>
<span className="material-symbols-outlined text-[18px]">
{liveRefresh ? "sync" : "sync_disabled"}
</span>
</button>
)}
<button
onClick={scrollToBottom}
title="Go to bottom"
className="p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
aria-label="Go to bottom"
>
<span className="material-symbols-outlined text-[18px]">vertical_align_bottom</span>
</button>
</div>
)}
</div>
{open && (
<div
ref={turnsBoxRef}
className="rounded-xl bg-black/5 dark:bg-black/30 border border-border max-h-150 overflow-y-auto p-3 space-y-2"
>
{earlierTurnsOmitted && (
<div className="text-xs text-text-muted italic mb-2">
Earlier turns not shown for this long-running conversation.
</div>
)}
{liveTurns.map((turn, i) => {
const isCurrent = turn.sourceCallLogId === currentLogId;
return (
<ChatBubble
key={i}
turn={turn}
isCurrent={isCurrent}
onClick={
turn.sourceCallLogId && !isCurrent
? () => onNavigateToLog(turn.sourceCallLogId)
: undefined
}
/>
);
})}
{nextId && (
<button
onClick={() => {
try {
sessionStorage.setItem(CONVERSATION_SCROLL_FLAG_KEY, "1");
} catch {
// sessionStorage unavailable — navigation still works, just without
// the scroll-into-view convenience
}
onNavigateToLog(nextId);
}}
className="w-full text-center text-xs text-primary hover:underline py-2"
>
View next message
</button>
)}
{polling && (
<div className="flex items-center justify-center gap-1.5 text-[10px] text-text-muted py-1">
{/* Same ring-spinner markup as the "in progress" status badge above
(not the shared <Spinner> icon glyph, which spins visibly off-axis). */}
<span className="inline-block h-3 w-3 rounded-full border-2 border-current border-t-transparent animate-spin" />
<span>watching for new turns</span>
</div>
)}
{allTurns.map((turn, i) => (
<ChatBubble key={i} turn={turn} />
))}
</div>
)}
</div>
@@ -481,7 +379,6 @@ export default function RequestLoggerDetail({
onNext,
relatedLogs = [],
onSelectRelated,
onNavigateToLog,
}) {
// Close on Escape key
useEffect(() => {
@@ -696,22 +593,30 @@ export default function RequestLoggerDetail({
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onPrevious}
disabled={!onPrevious}
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label="Previous request"
>
<span className="material-symbols-outlined text-[18px]">chevron_left</span>
</button>
<button
onClick={onNext}
disabled={!onNext}
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label="Next request"
>
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
</button>
{/* Only rendered when a caller actually wires up navigation (RequestLoggerV2's
list view) — a caller with no ordered-list context to navigate through
(conversations page, RequestTimeline) passes neither, so there's nothing
to show instead of a permanently-disabled dead button. */}
{(onPrevious || onNext) && (
<>
<button
onClick={onPrevious}
disabled={!onPrevious}
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label="Previous request"
>
<span className="material-symbols-outlined text-[18px]">chevron_left</span>
</button>
<button
onClick={onNext}
disabled={!onNext}
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label="Next request"
>
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
</button>
</>
)}
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
@@ -1142,18 +1047,7 @@ export default function RequestLoggerDetail({
</div>
) : (
<>
{Array.isArray(detail?.conversationTurns) && detail.conversationTurns.length > 0 && (
<ConversationTranscriptSection
key={log.id}
turns={detail.conversationTurns}
nextId={detail.conversationNextId ?? null}
isLatest={detail.conversationIsLatest ?? false}
lastSeenAt={detail.conversationLastSeenAt ?? null}
earlierTurnsOmitted={detail.conversationEarlierTurnsOmitted ?? false}
currentLogId={log.id}
onNavigateToLog={onNavigateToLog}
/>
)}
<ConversationContextSection key={log.id} log={log} detail={detail} />
{streamChunks && streamChunks.provider && (
<StreamSection

View File

@@ -81,9 +81,7 @@ test("normalizes OpenAI assistant tool_calls into tool_use blocks", () => {
test("normalizes OpenAI tool role into tool_result", () => {
const req = makeReq({
requestBody: JSON.stringify({
messages: [
{ role: "tool", tool_call_id: "call-1", content: "sunny" },
],
messages: [{ role: "tool", tool_call_id: "call-1", content: "sunny" }],
}),
});
const conv = normalizeConversation(req);
@@ -94,6 +92,79 @@ test("normalizes OpenAI tool role into tool_result", () => {
assert.equal(blk.tool_use_id, "call-1");
});
test("normalizes Responses API function_call/function_call_output items (no `role` field) into tool_use/tool_result turns", () => {
// Real OpenClaw traffic on the Responses API sends bare
// {type:"function_call"}/{type:"function_call_output"} items with NO
// `role` field at all — previously silently dropped (2026-08-06 bug:
// request 1785975096139-6627d2 showed zero tool calls in the Conversation
// Context panel despite the artifact having real function_call/
// function_call_output items throughout).
const req = makeReq({
path: "/v1/responses",
requestBody: JSON.stringify({
input: [
{ role: "user", content: [{ type: "input_text", text: "run ls" }] },
{
type: "function_call",
call_id: "call_00_abc",
name: "exec",
arguments: '{"command":"ls"}',
},
{
type: "function_call_output",
call_id: "call_00_abc",
output: "file1.txt\nfile2.txt",
},
],
}),
});
const conv = normalizeConversation(req);
assert.ok(conv);
assert.equal(conv.request.length, 3);
assert.equal(conv.request[1].role, "assistant");
const toolUse = conv.request[1].blocks[0] as {
type: "tool_use";
id: string;
name: string;
input: unknown;
};
assert.equal(toolUse.type, "tool_use");
assert.equal(toolUse.id, "call_00_abc");
assert.equal(toolUse.name, "exec");
assert.deepEqual(toolUse.input, { command: "ls" });
assert.equal(conv.request[2].role, "tool");
const toolResult = conv.request[2].blocks[0] as {
type: "tool_result";
tool_use_id: string;
content: unknown;
};
assert.equal(toolResult.type, "tool_result");
assert.equal(toolResult.tool_use_id, "call_00_abc");
assert.equal(toolResult.content, "file1.txt\nfile2.txt");
});
test("normalizes Responses API reasoning items (no `role` field) into an assistant text turn", () => {
const req = makeReq({
path: "/v1/responses",
requestBody: JSON.stringify({
input: [
{
type: "reasoning",
summary: [{ type: "summary_text", text: "Thinking about the request." }],
},
],
}),
});
const conv = normalizeConversation(req);
assert.ok(conv);
assert.equal(conv.request.length, 1);
assert.equal(conv.request[0].role, "assistant");
assert.equal(conv.request[0].blocks[0].type, "text");
assert.equal((conv.request[0].blocks[0] as { text: string }).text, "Thinking about the request.");
});
test("normalizes Anthropic request with top-level system + tool_use response", () => {
const req = makeReq({
host: "api.anthropic.com",

View File

@@ -1,240 +0,0 @@
/**
* 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");
});