mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user