fix(conversations): show a pending spinner for unresolved tool nodes instead of "(empty)" (#12727)

Validado numa worktree combinada com a onda de dashboard/monitoring desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-api-typecheck OK (289), check-file-size OK após rebaseline, 130/131 nos testes focados — a falha restante é asserção de tempo de parede sob carga, verde 6/6 isolada.

"(empty)" para um nó de ferramenta ainda não resolvido é informação errada, não ausência de informação — o usuário lê como "não retornou nada". Spinner de pendente diz a verdade.
This commit is contained in:
Markus Hartung
2026-09-10 15:42:38 +02:00
committed by GitHub
parent 3e2a6d8f35
commit b516e95262
5 changed files with 165 additions and 52 deletions

View File

@@ -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

View File

@@ -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 };
}

View File

@@ -25,6 +25,17 @@ export function MessageContent({ blocks }: MessageContentProps) {
if (block.type === "tool_result") {
return <ToolResultBlock key={i} toolUseId={block.tool_use_id} content={block.content} />;
}
if (block.type === "pending") {
return (
<div
key={i}
className="flex items-center gap-2 text-xs text-text-muted italic px-1 py-0.5"
>
<span className="inline-block h-3 w-3 rounded-full border-2 border-current border-t-transparent animate-spin shrink-0" />
loading...
</div>
);
}
return null;
})}
</div>

View File

@@ -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";

View File

@@ -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<Parameters<typeof toTurn>[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");
});