mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
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:
@@ -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
|
||||
|
||||
71
src/app/(dashboard)/dashboard/conversations/toTurn.ts
Normal file
71
src/app/(dashboard)/dashboard/conversations/toTurn.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
74
tests/unit/conversations-pending-tool-node.test.ts
Normal file
74
tests/unit/conversations-pending-tool-node.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user