diff --git a/src/app/(dashboard)/dashboard/conversations/page.tsx b/src/app/(dashboard)/dashboard/conversations/page.tsx index 14b8bdf4d7..7c48482f4f 100644 --- a/src/app/(dashboard)/dashboard/conversations/page.tsx +++ b/src/app/(dashboard)/dashboard/conversations/page.tsx @@ -8,7 +8,7 @@ import { copyToClipboard } from "@/shared/utils/clipboard"; import RequestLoggerDetail from "@/shared/components/RequestLoggerDetail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { ChatBubble } from "@/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble"; -import type { NormalizedBlock, NormalizedTurn } from "@/mitm/inspector/types"; +import { toTurn, type ConversationTurn } from "./toTurn"; interface ConversationRow { id: string; @@ -47,17 +47,6 @@ function ActiveSpinner() { ); } -interface ConversationTurn { - seq: number; - id: string; - parentId: string | null; - role: string; - textPreview: string; - blockKind: string; - toolName: string | null; - firstSeenAt: string; -} - interface ConversationTurnsPage { nodes: ConversationTurn[]; hasMore: boolean; @@ -123,45 +112,6 @@ function ContinuationBadge({ isGenuine }: { isGenuine: boolean }) { ); } -/** - * Builds the exact NormalizedBlock (src/mitm/inspector/types.ts) the - * request-detail panel already builds from buildRequestTurns/ - * buildResponseTurns, so a tool call/result renders through the very same - * ChatBubble → MessageContent → ToolCallBlock/ToolResultBlock pipeline as - * the detail view — not a parallel implementation. `textPreview` round- - * tripped through JSON for a structured tool_use/tool_result turn; parse it - * best-effort so the block gets a real object, not a JSON string. - */ -function toTurn(node: ConversationTurn): NormalizedTurn { - const role: NormalizedTurn["role"] = - node.role === "system" || node.role === "user" || node.role === "assistant" - ? node.role - : "tool"; - - let block: NormalizedBlock; - if (node.blockKind === "tool_use") { - let input: unknown = node.textPreview; - try { - input = JSON.parse(node.textPreview); - } catch { - // Arguments weren't valid JSON — show the raw string. - } - block = { type: "tool_use", id: node.id.slice(0, 12), name: node.toolName ?? "tool", input }; - } else if (node.blockKind === "tool_result") { - let content: unknown = node.textPreview; - try { - content = JSON.parse(node.textPreview); - } catch { - // Not JSON — show the raw string. - } - block = { type: "tool_result", tool_use_id: node.id.slice(0, 12), content }; - } else { - block = { type: "text", text: node.textPreview || "_(empty)_" }; - } - - return { role, blocks: [block], timestamp: node.firstSeenAt }; -} - /** * Renders a conversation's turns top to bottom, oldest first — always a * flat, chronological list. Every OmniRoute conversation is a single diff --git a/src/app/(dashboard)/dashboard/conversations/toTurn.ts b/src/app/(dashboard)/dashboard/conversations/toTurn.ts new file mode 100644 index 0000000000..9262bc2ff8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/conversations/toTurn.ts @@ -0,0 +1,71 @@ +import type { NormalizedBlock, NormalizedTurn } from "@/mitm/inspector/types"; + +export interface ConversationTurn { + seq: number; + id: string; + parentId: string | null; + role: string; + textPreview: string; + blockKind: string; + toolName: string | null; + firstSeenAt: string; +} + +/** + * Builds the exact NormalizedBlock (src/mitm/inspector/types.ts) the + * request-detail panel already builds from buildRequestTurns/ + * buildResponseTurns, so a tool call/result renders through the very same + * ChatBubble → MessageContent → ToolCallBlock/ToolResultBlock pipeline as + * the detail view — not a parallel implementation. `textPreview` round- + * tripped through JSON for a structured tool_use/tool_result turn; parse it + * best-effort so the block gets a real object, not a JSON string. + * + * Kept out of page.tsx (a "use client" component that pulls in ChatBubble/ + * MarkdownMessage's dependency tree) so this pure ConversationTurn -> + * NormalizedTurn mapping stays unit-testable on its own. + */ +export function toTurn(node: ConversationTurn): NormalizedTurn { + const role: NormalizedTurn["role"] = + node.role === "system" || node.role === "user" || node.role === "assistant" + ? node.role + : "tool"; + + let block: NormalizedBlock; + if (node.blockKind === "tool_use") { + let input: unknown = node.textPreview; + try { + input = JSON.parse(node.textPreview); + } catch { + // Arguments weren't valid JSON — show the raw string. + } + block = { type: "tool_use", id: node.id.slice(0, 12), name: node.toolName ?? "tool", input }; + } else if (node.blockKind === "tool_result") { + let content: unknown = node.textPreview; + try { + content = JSON.parse(node.textPreview); + } catch { + // Not JSON — show the raw string. + } + block = { type: "tool_result", tool_use_id: node.id.slice(0, 12), content }; + } else if (!node.textPreview) { + // The tree API records a node's identity (role/blockKind) the moment + // the turn is recorded, independent of when its display content + // resolves from the owning call-log artifact -- a request still in + // flight has a real node (any role: user, assistant, or tool) with + // nothing to show yet, and /api/conversations/[id]/tree's own + // blockKind ?? "text" fallback can't tell that apart from a + // permanently-purged artifact. Live traffic shows this lag hits every + // role, not just tool nodes (a user/assistant turn's own textPreview + // resolves through the same lazy pipeline) -- so any empty text node + // gets the same treatment. In practice this is near-always transient: + // the same poll that already refreshes this page (ConversationLogView's + // activeCallLogId-driven effect) picks up the real content within a + // tick or two once the artifact lands. Show that instead of a bare + // "(empty)" that reads as broken rather than in progress. + block = { type: "pending" }; + } else { + block = { type: "text", text: node.textPreview || "_(empty)_" }; + } + + return { role, blocks: [block], timestamp: node.firstSeenAt }; +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/MessageContent.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/MessageContent.tsx index ee76d97da1..bd8bd51d41 100644 --- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/MessageContent.tsx +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/MessageContent.tsx @@ -25,6 +25,17 @@ export function MessageContent({ blocks }: MessageContentProps) { if (block.type === "tool_result") { return ; } + if (block.type === "pending") { + return ( +
+ + loading... +
+ ); + } return null; })} diff --git a/src/mitm/inspector/types.ts b/src/mitm/inspector/types.ts index c74c694f82..8fdab4dc3f 100644 --- a/src/mitm/inspector/types.ts +++ b/src/mitm/inspector/types.ts @@ -67,7 +67,14 @@ export const InterceptedRequestSchema = z.object({ export type NormalizedBlock = | { type: "text"; text: string } | { type: "tool_use"; id: string; name: string; input: unknown } - | { type: "tool_result"; tool_use_id: string; content: unknown }; + | { type: "tool_result"; tool_use_id: string; content: unknown } + // A tool identity node whose display content hasn't resolved from its + // call-log artifact yet (see resolveTurnDisplayContent / + // /api/conversations/[id]/tree's own doc comment) -- distinct from a real + // empty text reply. Only ever produced by /dashboard/conversations while + // the owning request is still in flight; every other NormalizedBlock + // producer (buildRequestTurns/buildResponseTurns) never emits this. + | { type: "pending" }; export interface NormalizedTurn { role: "system" | "user" | "assistant" | "tool"; diff --git a/tests/unit/conversations-pending-tool-node.test.ts b/tests/unit/conversations-pending-tool-node.test.ts new file mode 100644 index 0000000000..1e045fca89 --- /dev/null +++ b/tests/unit/conversations-pending-tool-node.test.ts @@ -0,0 +1,74 @@ +/** + * Regression test for /dashboard/conversations's live-turn rendering. + * + * conversation_turn_nodes rows record identity (role/blockKind) synchronously + * the moment a turn is recorded; display content (textPreview) resolves + * lazily from the owning call-log artifact (resolveTurnDisplayContent). While + * that resolution is still in flight, /api/conversations/[id]/tree's own + * `blockKind ?? "text"` fallback makes a genuinely-in-progress node + * indistinguishable from a permanently-empty one at the API layer. toTurn() + * previously rendered an empty tool node as a distinct `pending` block but + * still showed a bare "_(empty)_" text bubble for user/assistant nodes with + * no textPreview yet -- live traffic showed the same resolution lag hits + * every role, not just tool, so this proves ANY role with no textPreview + * (in the plain-text fallback branch) now maps to `pending`, without + * changing tool_use/tool_result mapping. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { toTurn } = await import("../../src/app/(dashboard)/dashboard/conversations/toTurn.ts"); + +function node(overrides: Partial[0]>) { + return { + seq: 1, + id: "01234567890123456789", + parentId: null, + role: "assistant", + textPreview: "", + blockKind: "text", + toolName: null, + firstSeenAt: "2026-09-04T00:00:00.000Z", + ...overrides, + }; +} + +test("toTurn renders an unresolved tool node as pending, not a bare empty text bubble", () => { + const turn = toTurn(node({ role: "tool", blockKind: "text", textPreview: "" })); + assert.deepEqual(turn.blocks, [{ type: "pending" }]); +}); + +test("toTurn renders an unresolved assistant node as pending too", () => { + const turn = toTurn(node({ role: "assistant", blockKind: "text", textPreview: "" })); + assert.deepEqual(turn.blocks, [{ type: "pending" }]); +}); + +test("toTurn renders an unresolved user node as pending too", () => { + const turn = toTurn(node({ role: "user", blockKind: "text", textPreview: "" })); + assert.deepEqual(turn.blocks, [{ type: "pending" }]); +}); + +test("toTurn renders a resolved assistant reply's real content once textPreview lands, not pending", () => { + const turn = toTurn( + node({ role: "assistant", blockKind: "text", textPreview: "the real reply" }) + ); + assert.deepEqual(turn.blocks, [{ type: "text", text: "the real reply" }]); +}); + +test("toTurn renders a resolved tool node's real content once textPreview lands, not pending", () => { + const turn = toTurn(node({ role: "tool", blockKind: "text", textPreview: "the real result" })); + assert.deepEqual(turn.blocks, [{ type: "text", text: "the real result" }]); +}); + +test("toTurn leaves tool_use/tool_result mapping unaffected", () => { + const toolUse = toTurn( + node({ role: "tool", blockKind: "tool_use", toolName: "search", textPreview: '{"q":"x"}' }) + ); + assert.equal(toolUse.blocks[0]!.type, "tool_use"); + + const toolResult = toTurn( + node({ role: "tool", blockKind: "tool_result", textPreview: '{"ok":true}' }) + ); + assert.equal(toolResult.blocks[0]!.type, "tool_result"); +});