mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-30 23:20:35 +00:00
feat(frontend): add expandable generic tool details in debug mode (#5309)
* feat(frontend): add expandable generic tool details in debug mode * fix: address tool call details review feedback * fix: preserve existing ellipsis keys in tool previews * fix(frontend): preserve tool preview property names * fix(frontend): keep bounded tool previews structurally complete * fix(frontend): avoid repeated array preview truncation markers * fix(frontend): preserve precision in tool result previews * fix(frontend): coalesce generated array tail markers
This commit is contained in:
parent
806a5bd427
commit
1ee93cd186
11
README.md
11
README.md
@ -502,6 +502,17 @@ See the [Sandbox Configuration Guide](backend/docs/CONFIGURATION.md#sandbox) to
|
||||
|
||||
#### MCP Server
|
||||
|
||||
In the chat UI, enable **Token Usage → Debug** to inspect generic/MCP tool calls.
|
||||
Each **Tool details** panel starts collapsed and shows the tool name, call ID,
|
||||
input, and received result or explicit error. Large previews are truncated;
|
||||
fields whose names exceed the remaining preview budget are omitted rather than renamed.
|
||||
Structured previews retain complete JSON syntax, including escaped strings and closing delimiters.
|
||||
Array previews stop when the text budget cannot display another element; literal ellipsis values are preserved.
|
||||
Consecutive generated markers at an array's end share one ellipsis indicating an omitted suffix; markers before later values retain their positions.
|
||||
Text results retain their original representation, including large numeric IDs and duplicate JSON keys, without reparsing. Text exceeding the limit is shown as a prefix with a truncation notice; structured objects and arrays are formatted separately.
|
||||
Copy actions copy only the displayed preview. This is a frontend view of data
|
||||
already received by the browser, without an additional secret-redaction layer.
|
||||
|
||||
DeerFlow supports configurable MCP servers and skills to extend its capabilities.
|
||||
For HTTP/SSE MCP servers, OAuth token flows are supported (`client_credentials`, `refresh_token`).
|
||||
For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_timeout`; durable background-task calls honor the same setting for HTTP/SSE servers as well.
|
||||
|
||||
@ -101,6 +101,10 @@ Edit-and-rerun is deliberately latest-turn-only. `core/messages/utils.ts::getLat
|
||||
|
||||
`MessageGroup` builds its tool-result and browser-preview lookups once per processing group before converting messages to steps. The lookup preserves the first non-empty result and first screenshot-bearing browser view for each tool-call ID, matching the streamed-message display semantics without repeatedly scanning the full group for every tool call.
|
||||
|
||||
Generic tool details receive the explicit Debug flag from `MessageGroup`, independent of token statistics. `tool-call-details.tsx` mounts payload previews only while expanded; `core/messages/tool-detail-preview.ts` caps text output at 12,000 characters, visits at most 12,000 values, and limits nesting to six levels. Serialization budgets include escaped strings, punctuation, indentation, closing delimiters, and truncation markers so structured previews remain valid JSON without a final mid-token slice. Generic calls keep the original ToolMessage content/status. Text is displayed verbatim (or as a bounded prefix), never passed through JSON.parse: reparsing can round numeric IDs, discard duplicate keys, and alter quoted strings. Objects/arrays are formatted only on expansion; specialized renderers retain their existing result conversion. An empty received payload is distinct from a missing message, and neither implies a running/completed state. Copy actions use the shared clipboard fallback, copy the displayed preview, and reset feedback after two seconds. Truncated containers include an inline ellipsis unless an object has already emitted a real ellipsis key; in that case, only the external truncation notice is guaranteed. Arrays stop after a child collapses because the text budget is exhausted, avoiding repeated trailing markers while preserving literal ellipsis elements. Cycle, accessor, and depth-limit markers do not suppress later siblings. Truncated string prefixes preserve complete UTF-16 surrogate pairs. Object traversal stops before a key exceeds the remaining text budget; never shorten property names, since shortened keys can collide with real data. Renderer routing and result conversion share `getToolCallKind`. Disclosure accessible names include the tool name and call ID.
|
||||
|
||||
Array previews coalesce consecutive generated markers only at the end into one omitted-suffix notice. The serializer tracks marker provenance and complete entry boundaries; it must not deduplicate literal ellipsis values or shift the indices of later real elements. A cycle/accessor/depth-limit tail can therefore share one notice while the same markers in the middle retain their positions.
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Server Components by default**, `"use client"` only for interactive components
|
||||
|
||||
@ -48,6 +48,7 @@ import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { ToolCallDetails } from "./tool-call-details";
|
||||
|
||||
interface MessageGroupProps {
|
||||
className?: string;
|
||||
@ -264,6 +265,7 @@ function MessageGroupComponent({
|
||||
isLast={options?.isLast}
|
||||
isLoading={isLoading}
|
||||
deferBrowserPreview={deferBrowserPreviews}
|
||||
showDetails={showTokenDebugSummaries}
|
||||
tokenDebugStep={
|
||||
debugStep && !debugStep.sharedAttribution ? debugStep : undefined
|
||||
}
|
||||
@ -571,6 +573,26 @@ function browserToolLabel(
|
||||
}
|
||||
}
|
||||
|
||||
// Shared routing for result conversion and specialized rendering.
|
||||
function getToolCallKind(name: string) {
|
||||
if (name.startsWith("browser_")) return "browser";
|
||||
switch (name) {
|
||||
case "web_search":
|
||||
case "image_search":
|
||||
case "web_fetch":
|
||||
case "ls":
|
||||
case "read_file":
|
||||
case "write_file":
|
||||
case "str_replace":
|
||||
case "bash":
|
||||
case "ask_clarification":
|
||||
case "write_todos":
|
||||
return name;
|
||||
default:
|
||||
return "generic";
|
||||
}
|
||||
}
|
||||
|
||||
function ToolCall({
|
||||
id,
|
||||
messageId,
|
||||
@ -581,6 +603,8 @@ function ToolCall({
|
||||
isLoading = false,
|
||||
deferBrowserPreview = false,
|
||||
tokenDebugStep,
|
||||
showDetails = false,
|
||||
resultMessage,
|
||||
browserView,
|
||||
threadId,
|
||||
}: {
|
||||
@ -593,10 +617,13 @@ function ToolCall({
|
||||
isLoading?: boolean;
|
||||
deferBrowserPreview?: boolean;
|
||||
tokenDebugStep?: TokenDebugStep;
|
||||
showDetails?: boolean;
|
||||
resultMessage?: Extract<Message, { type: "tool" }>;
|
||||
browserView?: BrowserViewMeta;
|
||||
threadId?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const kind = getToolCallKind(name);
|
||||
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
|
||||
useArtifacts();
|
||||
const browserViewPanel = useMaybeBrowserView();
|
||||
@ -610,7 +637,7 @@ function ToolCall({
|
||||
fallback
|
||||
);
|
||||
const writeFilePath =
|
||||
(name === "write_file" || name === "str_replace") &&
|
||||
(kind === "write_file" || kind === "str_replace") &&
|
||||
typeof args.path === "string"
|
||||
? args.path
|
||||
: undefined;
|
||||
@ -644,7 +671,7 @@ function ToolCall({
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [autoOpenArtifactUrl, select, selectedArtifact, setOpen]);
|
||||
|
||||
if (name.startsWith("browser_")) {
|
||||
if (kind === "browser") {
|
||||
const shot = browserView?.screenshot;
|
||||
const previewUrl =
|
||||
shot && threadId ? resolveArtifactURL(shot, threadId) : undefined;
|
||||
@ -691,7 +718,7 @@ function ToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "web_search") {
|
||||
} else if (kind === "web_search") {
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
if (typeof args.query === "string") {
|
||||
label = t.toolCalls.searchOnWebFor(args.query);
|
||||
@ -715,7 +742,7 @@ function ToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "image_search") {
|
||||
} else if (kind === "image_search") {
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedImages;
|
||||
if (typeof args.query === "string") {
|
||||
label = t.toolCalls.searchForRelatedImagesFor(args.query);
|
||||
@ -763,7 +790,7 @@ function ToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "web_fetch") {
|
||||
} else if (kind === "web_fetch") {
|
||||
const url = (args as { url: string })?.url;
|
||||
let title = url;
|
||||
if (typeof result === "string") {
|
||||
@ -792,7 +819,7 @@ function ToolCall({
|
||||
</ChainOfThoughtSearchResult>
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "ls") {
|
||||
} else if (kind === "ls") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
@ -812,7 +839,7 @@ function ToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "read_file") {
|
||||
} else if (kind === "read_file") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
@ -832,7 +859,7 @@ function ToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "write_file" || name === "str_replace") {
|
||||
} else if (kind === "write_file" || kind === "str_replace") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
@ -860,7 +887,7 @@ function ToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "bash") {
|
||||
} else if (kind === "bash") {
|
||||
const description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
@ -889,7 +916,7 @@ function ToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "ask_clarification") {
|
||||
} else if (kind === "ask_clarification") {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
@ -897,7 +924,7 @@ function ToolCall({
|
||||
icon={MessageCircleQuestionMarkIcon}
|
||||
></ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "write_todos") {
|
||||
} else if (kind === "write_todos") {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
@ -913,7 +940,16 @@ function ToolCall({
|
||||
key={id}
|
||||
label={resolveLabel(description ?? t.toolCalls.useTool(name))}
|
||||
icon={WrenchIcon}
|
||||
></ChainOfThoughtStep>
|
||||
>
|
||||
{showDetails && (
|
||||
<ToolCallDetails
|
||||
name={name}
|
||||
callId={id}
|
||||
args={args}
|
||||
resultMessage={resultMessage}
|
||||
/>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -932,6 +968,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
resultMessage?: Extract<Message, { type: "tool" }>;
|
||||
browserView?: BrowserViewMeta;
|
||||
}
|
||||
|
||||
@ -950,6 +987,7 @@ interface BrowserViewMeta {
|
||||
function indexToolCallData(messages: Message[]) {
|
||||
const toolCallResults = new Map<string, string>();
|
||||
const browserViews = new Map<string, BrowserViewMeta>();
|
||||
const resultMessages = new Map<string, Extract<Message, { type: "tool" }>>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type !== "tool" || !message.tool_call_id) {
|
||||
@ -957,10 +995,13 @@ function indexToolCallData(messages: Message[]) {
|
||||
}
|
||||
|
||||
const toolCallId = message.tool_call_id;
|
||||
if (!resultMessages.has(toolCallId))
|
||||
resultMessages.set(toolCallId, message);
|
||||
if (!toolCallResults.has(toolCallId)) {
|
||||
const result = extractTextFromMessage(message);
|
||||
if (result) {
|
||||
toolCallResults.set(toolCallId, result);
|
||||
resultMessages.set(toolCallId, message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -976,12 +1017,13 @@ function indexToolCallData(messages: Message[]) {
|
||||
}
|
||||
}
|
||||
|
||||
return { browserViews, toolCallResults };
|
||||
return { browserViews, toolCallResults, resultMessages };
|
||||
}
|
||||
|
||||
function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
const steps: CoTStep[] = [];
|
||||
const { browserViews, toolCallResults } = indexToolCallData(messages);
|
||||
const { browserViews, toolCallResults, resultMessages } =
|
||||
indexToolCallData(messages);
|
||||
for (const [messageIndex, message] of messages.entries()) {
|
||||
if (message.type === "ai") {
|
||||
// Reasoning precedes the answer text it produced, so it is pushed first:
|
||||
@ -1020,7 +1062,9 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
const toolCallId = tool_call.id;
|
||||
if (toolCallId) {
|
||||
const toolCallResult = toolCallResults.get(toolCallId);
|
||||
if (toolCallResult) {
|
||||
step.resultMessage = resultMessages.get(toolCallId);
|
||||
// Generic details preserve received text; specialized tools retain their parsing.
|
||||
if (toolCallResult && getToolCallKind(tool_call.name) !== "generic") {
|
||||
try {
|
||||
const json = JSON.parse(toolCallResult);
|
||||
step.result = json;
|
||||
|
||||
116
frontend/src/components/workspace/messages/tool-call-details.tsx
Normal file
116
frontend/src/components/workspace/messages/tool-call-details.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { writeTextToClipboard } from "@/core/clipboard";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { formatToolDetail } from "@/core/messages/tool-detail-preview";
|
||||
|
||||
export function ToolCallDetails({
|
||||
name,
|
||||
callId,
|
||||
args,
|
||||
resultMessage,
|
||||
}: {
|
||||
name: string;
|
||||
callId?: string;
|
||||
args: Record<string, unknown>;
|
||||
resultMessage?: Extract<Message, { type: "tool" }>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelId = useId();
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`${t.toolCalls.details}: ${name}${callId ? ` (${callId})` : ""}`}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{t.toolCalls.details}
|
||||
</Button>
|
||||
{open && (
|
||||
<div id={panelId} className="space-y-3 rounded-md border p-3">
|
||||
<Payload label={t.toolCalls.toolName} value={name} />
|
||||
<Payload label={t.toolCalls.callId} value={callId ?? "—"} />
|
||||
<Payload label={t.toolCalls.input} value={args} />
|
||||
{resultMessage ? (
|
||||
<Payload
|
||||
label={
|
||||
resultMessage.status === "error"
|
||||
? t.toolCalls.error
|
||||
: t.toolCalls.result
|
||||
}
|
||||
value={resultMessage.content}
|
||||
/>
|
||||
) : (
|
||||
<p>{t.toolCalls.noResult}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Payload({ label, value }: { label: string; value: unknown }) {
|
||||
const { t } = useI18n();
|
||||
const preview = useMemo(() => formatToolDetail(value), [value]);
|
||||
const [copyStatus, setCopyStatus] = useState<{
|
||||
text: string;
|
||||
message: string;
|
||||
}>();
|
||||
const visibleText = preview.text === "" ? '""' : preview.text;
|
||||
useEffect(() => {
|
||||
if (!copyStatus) return;
|
||||
const timer = setTimeout(() => setCopyStatus(undefined), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [copyStatus]);
|
||||
async function copy() {
|
||||
try {
|
||||
if (!(await writeTextToClipboard(visibleText))) {
|
||||
throw new Error("Clipboard write failed");
|
||||
}
|
||||
setCopyStatus({
|
||||
text: visibleText,
|
||||
message: t.clipboard.copiedToClipboard,
|
||||
});
|
||||
} catch {
|
||||
setCopyStatus({
|
||||
text: visibleText,
|
||||
message: t.clipboard.failedToCopyToClipboard,
|
||||
});
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section aria-label={label} className="min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`${t.clipboard.copyToClipboard}: ${label}`}
|
||||
onClick={copy}
|
||||
>
|
||||
{t.clipboard.copyToClipboard}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="bg-muted max-h-64 overflow-auto rounded p-2 text-xs break-all whitespace-pre-wrap">
|
||||
{visibleText}
|
||||
</pre>
|
||||
{preview.text === "" && (
|
||||
<p className="text-xs">{t.toolCalls.emptyResult}</p>
|
||||
)}
|
||||
{preview.truncated && <p className="text-xs">{t.toolCalls.truncated}</p>}
|
||||
<span role="status" className="text-xs">
|
||||
{copyStatus?.text === visibleText ? copyStatus.message : ""}
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -802,6 +802,16 @@ export const enUS: Translations = {
|
||||
|
||||
// Tool calls
|
||||
toolCalls: {
|
||||
details: "Tool details",
|
||||
toolName: "Tool name",
|
||||
callId: "Call ID",
|
||||
input: "Input",
|
||||
result: "Result",
|
||||
error: "Error",
|
||||
noResult: "No result received",
|
||||
emptyResult: "Empty result",
|
||||
truncated:
|
||||
"Preview truncated; copying includes only the displayed preview.",
|
||||
moreSteps: (count: number) => `${count} more step${count === 1 ? "" : "s"}`,
|
||||
lessSteps: "Less steps",
|
||||
executeCommand: "Execute command",
|
||||
|
||||
@ -666,6 +666,15 @@ export interface Translations {
|
||||
|
||||
// Tool calls
|
||||
toolCalls: {
|
||||
details: string;
|
||||
toolName: string;
|
||||
callId: string;
|
||||
input: string;
|
||||
result: string;
|
||||
error: string;
|
||||
noResult: string;
|
||||
emptyResult: string;
|
||||
truncated: string;
|
||||
moreSteps: (count: number) => string;
|
||||
lessSteps: string;
|
||||
executeCommand: string;
|
||||
|
||||
@ -767,6 +767,15 @@ export const zhCN: Translations = {
|
||||
|
||||
// Tool calls
|
||||
toolCalls: {
|
||||
details: "工具详情",
|
||||
toolName: "工具名称",
|
||||
callId: "调用 ID",
|
||||
input: "输入",
|
||||
result: "结果",
|
||||
error: "错误",
|
||||
noResult: "尚未收到结果",
|
||||
emptyResult: "空结果",
|
||||
truncated: "预览已截断;复制仅包含当前显示的内容。",
|
||||
moreSteps: (count: number) => `查看其他 ${count} 个步骤`,
|
||||
lessSteps: "隐藏步骤",
|
||||
executeCommand: "执行命令",
|
||||
|
||||
144
frontend/src/core/messages/tool-detail-preview.ts
Normal file
144
frontend/src/core/messages/tool-detail-preview.ts
Normal file
@ -0,0 +1,144 @@
|
||||
export const TOOL_PREVIEW_LIMIT = 12_000;
|
||||
const MAX_NODES = TOOL_PREVIEW_LIMIT;
|
||||
const MAX_DEPTH = 6;
|
||||
|
||||
// UTF-16 limits must not leave half of a surrogate pair in the visible prefix.
|
||||
function textPrefix(text: string, length: number): string {
|
||||
let end = Math.min(text.length, Math.max(0, length));
|
||||
if (
|
||||
end > 0 &&
|
||||
end < text.length &&
|
||||
text.charCodeAt(end - 1) >= 0xd800 &&
|
||||
text.charCodeAt(end - 1) <= 0xdbff &&
|
||||
text.charCodeAt(end) >= 0xdc00 &&
|
||||
text.charCodeAt(end) <= 0xdfff
|
||||
)
|
||||
end--;
|
||||
return text.slice(0, end);
|
||||
}
|
||||
|
||||
/** Serialize bounded previews with space reserved for complete JSON tokens. */
|
||||
export function formatToolDetail(value: unknown): {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
} {
|
||||
let truncated = false;
|
||||
let nodes = 0;
|
||||
let budgetCollapses = 0;
|
||||
let generatedMarkers = 0;
|
||||
const seen = new WeakSet<object>();
|
||||
const marker = (budgetCollapsed = false) => {
|
||||
truncated = true;
|
||||
generatedMarkers++;
|
||||
if (budgetCollapsed) budgetCollapses++;
|
||||
return JSON.stringify("…");
|
||||
};
|
||||
const quote = (text: string, budget: number): string => {
|
||||
// Bound the input before escaping; escaping can expand each character.
|
||||
const candidate = JSON.stringify(textPrefix(text, budget));
|
||||
if (text.length <= budget && candidate.length <= budget) return candidate;
|
||||
truncated = true;
|
||||
let low = 0;
|
||||
let high = Math.min(text.length, budget);
|
||||
while (low < high) {
|
||||
const middle = Math.ceil((low + high) / 2);
|
||||
if (JSON.stringify(textPrefix(text, middle) + "…").length <= budget)
|
||||
low = middle;
|
||||
else high = middle - 1;
|
||||
}
|
||||
const prefix = textPrefix(text, low);
|
||||
if (prefix === "") return marker(true);
|
||||
return JSON.stringify(prefix + "…");
|
||||
};
|
||||
const visit = (item: unknown, depth: number, budget: number): string => {
|
||||
if (++nodes > MAX_NODES || depth > MAX_DEPTH) return marker();
|
||||
if (typeof item === "string") return quote(item, budget);
|
||||
if (
|
||||
item === null ||
|
||||
typeof item === "boolean" ||
|
||||
typeof item === "number"
|
||||
) {
|
||||
const token = JSON.stringify(item);
|
||||
return token.length <= budget ? token : marker(true);
|
||||
}
|
||||
if (typeof item === "bigint") return quote(item.toString(), budget);
|
||||
if (typeof item !== "object") return quote(typeof item, budget);
|
||||
if (seen.has(item)) return marker();
|
||||
const array = Array.isArray(item);
|
||||
const indent = " ".repeat(depth + 1);
|
||||
const closing = "\n" + " ".repeat(depth) + (array ? "]" : "}");
|
||||
const notice = array ? '"…"' : '"…": "…"';
|
||||
// Reserve the closing delimiter and a possible final truncation entry.
|
||||
const reserve = closing.length + 2 + indent.length + notice.length;
|
||||
if (budget < 1 + reserve) return marker(true);
|
||||
seen.add(item);
|
||||
let output = array ? "[" : "{";
|
||||
let count = 0;
|
||||
let hasEllipsis = false;
|
||||
let tailMarkerEnd: number | undefined;
|
||||
const append = (entry: string, generated: boolean) => {
|
||||
output += entry;
|
||||
// Remember a complete entry boundary, never a character-budget cut.
|
||||
// A real later value clears it so middle array indices stay intact.
|
||||
if (array && generated) tailMarkerEnd ??= output.length;
|
||||
else tailMarkerEnd = undefined;
|
||||
};
|
||||
for (const key in item) {
|
||||
if (!Object.prototype.hasOwnProperty.call(item, key)) continue;
|
||||
const prefix = (count ? ",\n" : "\n") + indent;
|
||||
const available = budget - output.length - prefix.length - reserve;
|
||||
// Never shorten a property name, including its JSON escape sequences.
|
||||
const encodedKey = array
|
||||
? ""
|
||||
: key.length <= available
|
||||
? JSON.stringify(key) + ": "
|
||||
: null;
|
||||
if (
|
||||
nodes >= MAX_NODES ||
|
||||
encodedKey === null ||
|
||||
available - encodedKey.length < 3
|
||||
) {
|
||||
truncated = true;
|
||||
if (array || !hasEllipsis) append(prefix + notice, true);
|
||||
break;
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(item, key);
|
||||
const previousBudgetCollapses = budgetCollapses;
|
||||
const previousGeneratedMarkers = generatedMarkers;
|
||||
const child =
|
||||
descriptor && "value" in descriptor
|
||||
? visit(descriptor.value, depth + 1, available - encodedKey.length)
|
||||
: marker();
|
||||
// Only exhausted space ends the array. Cycles/accessors/depth limits must
|
||||
// not hide later siblings, and a literal ellipsis is ordinary data.
|
||||
if (
|
||||
array &&
|
||||
budgetCollapses > previousBudgetCollapses &&
|
||||
child === notice
|
||||
) {
|
||||
append(prefix + notice, true);
|
||||
break;
|
||||
}
|
||||
append(
|
||||
prefix + encodedKey + child,
|
||||
generatedMarkers > previousGeneratedMarkers && child === notice,
|
||||
);
|
||||
count++;
|
||||
if (key === "…") hasEllipsis = true;
|
||||
}
|
||||
seen.delete(item);
|
||||
if (tailMarkerEnd !== undefined) output = output.slice(0, tailMarkerEnd);
|
||||
return output + (output.length === 1 ? (array ? "]" : "}") : closing);
|
||||
};
|
||||
// Preserve tool text verbatim: JSON.parse can round IDs, drop duplicate keys,
|
||||
// and change quoted strings. Structured values are already decoded by the SDK.
|
||||
if (typeof value === "string") {
|
||||
truncated = value.length > TOOL_PREVIEW_LIMIT;
|
||||
return {
|
||||
text: truncated ? textPrefix(value, TOOL_PREVIEW_LIMIT - 1) + "…" : value,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
const text = visit(value, 0, TOOL_PREVIEW_LIMIT);
|
||||
return { text, truncated };
|
||||
}
|
||||
135
frontend/tests/e2e/tool-call-details.spec.ts
Normal file
135
frontend/tests/e2e/tool-call-details.spec.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
const threadId = "00000000-0000-0000-0000-000000004389";
|
||||
|
||||
for (const { debug, content, status, label } of [
|
||||
{
|
||||
debug: false,
|
||||
content: "denied: " + "x".repeat(20000),
|
||||
status: "error",
|
||||
label: "Debug disabled",
|
||||
},
|
||||
{
|
||||
debug: true,
|
||||
content: "denied: " + "x".repeat(20000),
|
||||
status: "error",
|
||||
label: "bounded error",
|
||||
},
|
||||
{
|
||||
debug: true,
|
||||
content: '{"run_id":9223372036854775807,"ratio":0.1234567890123456789}',
|
||||
status: "success",
|
||||
label: "nested numeric precision",
|
||||
},
|
||||
{
|
||||
debug: true,
|
||||
content: "9223372036854775807",
|
||||
status: "success",
|
||||
label: "top-level numeric precision",
|
||||
},
|
||||
] as const) {
|
||||
test(`generic tool details: ${label}`, async ({ page }) => {
|
||||
await page.addInitScript((enabled) => {
|
||||
localStorage.setItem(
|
||||
"deerflow.local-settings",
|
||||
JSON.stringify({
|
||||
tokenUsage: {
|
||||
headerTotal: true,
|
||||
inlineMode: enabled ? "step_debug" : "per_turn",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, debug);
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: threadId,
|
||||
title: "Tool details",
|
||||
messages: [
|
||||
{ type: "human", id: "human", content: "Look up the run" },
|
||||
{
|
||||
type: "ai",
|
||||
id: "ai-tool",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call-4389",
|
||||
name: "mcp_lookup",
|
||||
args: { run_id: "run-4389" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool-result",
|
||||
tool_call_id: "call-4389",
|
||||
status,
|
||||
content,
|
||||
},
|
||||
{ type: "ai", id: "answer", content: "The lookup failed." },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// 默认 mock 关闭了 Token Usage;这里启用入口,但不提供任何调用统计。
|
||||
await page.route("**/api/models", (route) =>
|
||||
route.fulfill({ json: { models: [], token_usage: { enabled: true } } }),
|
||||
);
|
||||
await page.goto(`/workspace/chats/${threadId}`);
|
||||
await expect(
|
||||
page.getByText("The lookup failed.", { exact: true }),
|
||||
).toBeVisible();
|
||||
const trigger = page.getByRole("button", {
|
||||
name: "Tool details: mcp_lookup (call-4389)",
|
||||
exact: true,
|
||||
});
|
||||
if (!debug) {
|
||||
await expect(trigger).toHaveCount(0);
|
||||
return;
|
||||
}
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Input", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await trigger.click();
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Input", exact: true }),
|
||||
).toContainText("run-4389");
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Call ID", exact: true }),
|
||||
).toContainText("call-4389");
|
||||
const error = page.getByRole("region", {
|
||||
name: status === "error" ? "Error" : "Result",
|
||||
exact: true,
|
||||
});
|
||||
if (status === "success") {
|
||||
expect(await error.locator("pre").textContent()).toBe(content);
|
||||
await expect(
|
||||
error.getByText("Preview truncated", { exact: false }),
|
||||
).toHaveCount(0);
|
||||
await page
|
||||
.context()
|
||||
.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await error
|
||||
.getByRole("button", { name: "Copy to clipboard: Result", exact: true })
|
||||
.click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe(content);
|
||||
return;
|
||||
}
|
||||
await expect(error).toContainText("denied:");
|
||||
await expect(error).toContainText("Preview truncated");
|
||||
expect(
|
||||
(await error.locator("pre").textContent())?.length,
|
||||
).toBeLessThanOrEqual(12000);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath("tool-details.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await trigger.click();
|
||||
await expect(error).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,247 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { MessageGroup } from "@/components/workspace/messages/message-group";
|
||||
import { I18nContext } from "@/core/i18n/context";
|
||||
import { enUS } from "@/core/i18n/locales/en-US";
|
||||
|
||||
rs.mock("@/components/workspace/artifacts", () => ({
|
||||
useArtifacts: () => ({ setOpen: () => undefined, select: () => undefined }),
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
|
||||
const call: Message = {
|
||||
type: "ai",
|
||||
id: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{ id: "call-1", name: "mcp_lookup", args: { query: "example" } },
|
||||
],
|
||||
};
|
||||
function tool(
|
||||
content: string,
|
||||
status: "success" | "error" = "success",
|
||||
): Message {
|
||||
return {
|
||||
type: "tool",
|
||||
id: `tool-${content}`,
|
||||
tool_call_id: "call-1",
|
||||
content,
|
||||
status,
|
||||
};
|
||||
}
|
||||
function group(messages: Message[], debug = true) {
|
||||
return (
|
||||
<I18nContext.Provider
|
||||
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
|
||||
>
|
||||
<MessageGroup messages={messages} showTokenDebugSummaries={debug} />
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
function expand() {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Tool details: mcp_lookup (call-1)" }),
|
||||
);
|
||||
}
|
||||
|
||||
describe("generic tool details", () => {
|
||||
it("distinguishes multiple calls even when they use the same tool", () => {
|
||||
render(
|
||||
group([
|
||||
{
|
||||
...call,
|
||||
tool_calls: [
|
||||
{ id: "call-1", name: "mcp_lookup", args: {} },
|
||||
{ id: "call-2", name: "mcp_lookup", args: {} },
|
||||
],
|
||||
} as Message,
|
||||
]),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "1 more step" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Tool details: mcp_lookup (call-2)" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("region", { name: "Call ID" }).textContent,
|
||||
).toContain("call-2");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: "Tool details: mcp_lookup (call-1)" })
|
||||
.getAttribute("aria-expanded"),
|
||||
).toBe("false");
|
||||
});
|
||||
it("copies through the DOM fallback without navigator.clipboard and resets feedback", async () => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
const execCommand = rs.fn().mockImplementation(() => {
|
||||
expect(document.querySelector("textarea")?.value).toBe("fallback result");
|
||||
return true;
|
||||
});
|
||||
Object.defineProperty(document, "execCommand", {
|
||||
configurable: true,
|
||||
value: execCommand,
|
||||
});
|
||||
render(group([call, tool("fallback result")]));
|
||||
expand();
|
||||
const result = screen.getByRole("region", { name: "Result" });
|
||||
fireEvent.click(within(result).getByRole("button"));
|
||||
await waitFor(() =>
|
||||
expect(within(result).getByRole("status").textContent).toBe(
|
||||
enUS.clipboard.copiedToClipboard,
|
||||
),
|
||||
);
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
await waitFor(
|
||||
() => expect(within(result).getByRole("status").textContent).toBe(""),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
Reflect.deleteProperty(document, "execCommand");
|
||||
});
|
||||
it("uses Debug without token statistics and does no payload work while collapsed", () => {
|
||||
const args = {
|
||||
get query() {
|
||||
throw new Error("collapsed payload was read");
|
||||
},
|
||||
};
|
||||
const messages: Message[] = [
|
||||
{
|
||||
...call,
|
||||
tool_calls: [{ id: "call-1", name: "mcp_lookup", args }],
|
||||
} as Message,
|
||||
];
|
||||
const { rerender } = render(group(messages, false));
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "Tool details: mcp_lookup (call-1)",
|
||||
}),
|
||||
).toBeNull();
|
||||
rerender(group(messages));
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: "Tool details: mcp_lookup (call-1)" })
|
||||
.getAttribute("aria-expanded"),
|
||||
).toBe("false");
|
||||
expect(screen.queryByRole("region", { name: "Input" })).toBeNull();
|
||||
});
|
||||
it("matches the first nonempty result and its error status by call ID", () => {
|
||||
render(
|
||||
group([
|
||||
tool(""),
|
||||
tool('{"reason":"denied"}', "error"),
|
||||
call,
|
||||
tool("later result"),
|
||||
]),
|
||||
);
|
||||
expand();
|
||||
expect(screen.getByRole("region", { name: "Error" }).textContent).toContain(
|
||||
"denied",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("region", { name: "Call ID" }).textContent,
|
||||
).toContain("call-1");
|
||||
expect(screen.queryByText("later result")).toBeNull();
|
||||
});
|
||||
it("distinguishes missing, empty and falsy results during updates", () => {
|
||||
const { rerender } = render(group([call]));
|
||||
expand();
|
||||
expect(screen.getByText("No result received")).toBeTruthy();
|
||||
rerender(group([call, tool("")]));
|
||||
expect(screen.getByText("Empty result")).toBeTruthy();
|
||||
for (const content of ["null", "false", "0"]) {
|
||||
rerender(group([call, tool(content)]));
|
||||
expect(
|
||||
screen.getByRole("region", { name: "Result" }).querySelector("pre")
|
||||
?.textContent,
|
||||
).toBe(content);
|
||||
}
|
||||
});
|
||||
it("copies exactly the bounded preview and reports clipboard failures", async () => {
|
||||
const writeText = rs.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
render(group([call, tool("x".repeat(20000))]));
|
||||
expand();
|
||||
const result = screen.getByRole("region", { name: "Result" });
|
||||
expect(within(result).getByText(enUS.toolCalls.truncated)).toBeTruthy();
|
||||
fireEvent.click(within(result).getByRole("button"));
|
||||
await waitFor(() =>
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
result.querySelector("pre")?.textContent,
|
||||
),
|
||||
);
|
||||
writeText.mockRejectedValue(new Error("denied"));
|
||||
fireEvent.click(within(result).getByRole("button"));
|
||||
await waitFor(() =>
|
||||
expect(within(result).getByRole("status").textContent).toBe(
|
||||
enUS.clipboard.failedToCopyToClipboard,
|
||||
),
|
||||
);
|
||||
});
|
||||
it("keeps specialized tools unchanged and resets disclosure when Debug is disabled", () => {
|
||||
const { rerender } = render(group([call, tool("hello")]));
|
||||
expand();
|
||||
rerender(group([call], false));
|
||||
expect(screen.queryByRole("region", { name: "Input" })).toBeNull();
|
||||
rerender(group([call]));
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: "Tool details: mcp_lookup (call-1)" })
|
||||
.getAttribute("aria-expanded"),
|
||||
).toBe("false");
|
||||
rerender(
|
||||
group([
|
||||
{
|
||||
...call,
|
||||
tool_calls: [
|
||||
{ id: "call-1", name: "web_search", args: { query: "example" } },
|
||||
],
|
||||
} as Message,
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "Tool details: mcp_lookup (call-1)",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"9223372036854775807",
|
||||
'{"run_id":9223372036854775807,"ratio":0.1234567890123456789}',
|
||||
'{"run_id":1,"run_id":2}',
|
||||
'""',
|
||||
])(
|
||||
"displays and copies the exact received tool result: %s",
|
||||
async (content) => {
|
||||
const writeText = rs.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
render(group([call, tool(content)]));
|
||||
expand();
|
||||
const result = screen.getByRole("region", { name: "Result" });
|
||||
expect(result.querySelector("pre")?.textContent).toBe(content);
|
||||
expect(within(result).queryByText(enUS.toolCalls.truncated)).toBeNull();
|
||||
expect(within(result).queryByText(enUS.toolCalls.emptyResult)).toBeNull();
|
||||
fireEvent.click(within(result).getByRole("button"));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith(content));
|
||||
},
|
||||
);
|
||||
363
frontend/tests/unit/core/messages/tool-detail-preview.test.ts
Normal file
363
frontend/tests/unit/core/messages/tool-detail-preview.test.ts
Normal file
@ -0,0 +1,363 @@
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import {
|
||||
formatToolDetail,
|
||||
TOOL_PREVIEW_LIMIT,
|
||||
} from "@/core/messages/tool-detail-preview";
|
||||
|
||||
describe("formatToolDetail", () => {
|
||||
it("keeps short numeric lists complete beyond the old node budget", () => {
|
||||
for (const length of [200, 1000]) {
|
||||
const values = Array.from({ length }, (_, i) => i);
|
||||
const preview = formatToolDetail(values);
|
||||
expect(preview.truncated).toBe(false);
|
||||
expect(JSON.parse(preview.text)).toEqual(values);
|
||||
}
|
||||
});
|
||||
it("visibly marks truncated lists and objects", () => {
|
||||
for (const value of [Array(20000).fill(0), { text: "x".repeat(20000) }]) {
|
||||
const preview = formatToolDetail(value);
|
||||
expect(preview.truncated).toBe(true);
|
||||
expect(preview.text).toContain("…");
|
||||
expect(preview.text.length).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
}
|
||||
});
|
||||
it.each([42, { secret: "keep-me" }])(
|
||||
"preserves a real ellipsis key with value %j when object traversal is truncated",
|
||||
(original) => {
|
||||
const value: Record<string, unknown> = { "…": original };
|
||||
for (let i = 0; i < 20000; i++) value[`k${i}`] = 0;
|
||||
|
||||
const preview = formatToolDetail(value);
|
||||
|
||||
expect(preview.truncated).toBe(true);
|
||||
expect(
|
||||
preview.text.startsWith(
|
||||
JSON.stringify({ "…": original }, null, 2).slice(0, -2),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(preview.text.length).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
},
|
||||
);
|
||||
it("does not shorten a key into an existing ellipsis-suffixed key", () => {
|
||||
const original = { secret: "keep-me" };
|
||||
const value = {
|
||||
"k…": original,
|
||||
// Leave two characters for the next key, which used to become "k…".
|
||||
padding: "x".repeat(
|
||||
TOOL_PREVIEW_LIMIT - "k…secretkeep-mepadding".length - 2,
|
||||
),
|
||||
keyThatMustNotBeRenamed: 0,
|
||||
};
|
||||
|
||||
const preview = formatToolDetail(value);
|
||||
|
||||
expect(preview.truncated).toBe(true);
|
||||
expect(
|
||||
preview.text.startsWith(
|
||||
JSON.stringify({ "k…": original }, null, 2).slice(0, -2),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(preview.text.length).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
});
|
||||
it("omits an oversized key instead of displaying a renamed property", () => {
|
||||
const preview = formatToolDetail({
|
||||
["k".repeat(TOOL_PREVIEW_LIMIT + 1)]: 42,
|
||||
});
|
||||
|
||||
expect(preview.truncated).toBe(true);
|
||||
expect(JSON.parse(preview.text)).toEqual({ "…": "…" });
|
||||
});
|
||||
it("preserves falsy results and plain text and formats structured values", () => {
|
||||
for (const text of ["null", "false", "0", "", "plain text"]) {
|
||||
expect(formatToolDetail(text)).toEqual({ text, truncated: false });
|
||||
}
|
||||
expect(formatToolDetail({ run_id: 42 }).text).toBe('{\n "run_id": 42\n}');
|
||||
});
|
||||
it("bounds long strings before parsing or serializing", () => {
|
||||
const result = formatToolDetail({ content: "x".repeat(1_000_000) });
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.text.length).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
expect(formatToolDetail("x".repeat(1_000_000)).text.length).toBe(
|
||||
TOOL_PREVIEW_LIMIT,
|
||||
);
|
||||
});
|
||||
it("bounds wide and deep values without reading later getters", () => {
|
||||
const wide = Array.from({ length: 1000 }, (_, i) => i);
|
||||
Object.defineProperty(wide, "900", {
|
||||
get() {
|
||||
throw new Error("must not read");
|
||||
},
|
||||
});
|
||||
expect(formatToolDetail(wide).truncated).toBe(true);
|
||||
let deep: unknown = "leaf";
|
||||
for (let i = 0; i < 1000; i++) deep = { child: deep };
|
||||
expect(formatToolDetail(deep).truncated).toBe(true);
|
||||
});
|
||||
it("does not invoke getters or toJSON and handles cycles", () => {
|
||||
const value = {
|
||||
get secret() {
|
||||
throw new Error("must not read");
|
||||
},
|
||||
toJSON() {
|
||||
throw new Error("must not invoke");
|
||||
},
|
||||
};
|
||||
expect(formatToolDetail(value).truncated).toBe(true);
|
||||
const cycle: Record<string, unknown> = {};
|
||||
cycle.self = cycle;
|
||||
expect(formatToolDetail(cycle).truncated).toBe(true);
|
||||
});
|
||||
it("renders markup as plain source and limits escaped output", () => {
|
||||
expect(formatToolDetail("<script>alert(1)</script>").text).toBe(
|
||||
"<script>alert(1)</script>",
|
||||
);
|
||||
expect(
|
||||
formatToolDetail({ text: "\n".repeat(12000) }).text.length,
|
||||
).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps dense previews valid JSON with complete original property names", () => {
|
||||
const value = Object.fromEntries(
|
||||
Array.from({ length: 8000 }, (_, i) => [`k${i}`, i]),
|
||||
);
|
||||
const preview = formatToolDetail(value);
|
||||
const parsed = JSON.parse(preview.text) as Record<string, unknown>;
|
||||
expect(preview.truncated).toBe(true);
|
||||
expect(preview.text.length).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
for (const [key, child] of Object.entries(parsed)) {
|
||||
if (key !== "…") expect(child).toBe(value[key]);
|
||||
}
|
||||
});
|
||||
|
||||
it("accounts for quotes, escapes and indentation before accepting keys", () => {
|
||||
for (const key of ["L".repeat(11999), "\n".repeat(6000)]) {
|
||||
const preview = formatToolDetail({ [key]: "v".repeat(5000) });
|
||||
expect(JSON.parse(preview.text)).toEqual({ "…": "…" });
|
||||
expect(preview.truncated).toBe(true);
|
||||
}
|
||||
const preview = formatToolDetail({ nested: { text: '\n"\\'.repeat(12000) } });
|
||||
expect(() => JSON.parse(preview.text)).not.toThrow();
|
||||
expect(preview.text.length).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
expect(preview.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("stops arrays after a child collapses even if an earlier field was truncated", () => {
|
||||
const items = Array.from({ length: 4000 }, (_, i) => ({
|
||||
i,
|
||||
deep: { a: { b: i } },
|
||||
}));
|
||||
for (const value of [
|
||||
items,
|
||||
{
|
||||
earlier: {
|
||||
get hidden() {
|
||||
throw new Error("must not read");
|
||||
},
|
||||
},
|
||||
items,
|
||||
},
|
||||
]) {
|
||||
const preview = formatToolDetail(value);
|
||||
const parsed = JSON.parse(preview.text);
|
||||
const result = (Array.isArray(parsed) ? parsed : parsed.items) as unknown[];
|
||||
expect(preview.truncated).toBe(true);
|
||||
expect(preview.text.length).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
expect(result.at(-1)).toBe("…");
|
||||
expect(result.at(-2)).not.toBe("…");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves literal ellipsis array entries and the values after them", () => {
|
||||
expect(formatToolDetail(["…", "…", { keep: 42 }])).toEqual({
|
||||
text: JSON.stringify(["…", "…", { keep: 42 }], null, 2),
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("coalesces generated array tail markers without changing middle positions", () => {
|
||||
const tail: unknown[] = [42];
|
||||
tail.push(tail, tail);
|
||||
const middle: unknown[] = [];
|
||||
middle.push(middle, middle, 42);
|
||||
const literal: unknown[] = [];
|
||||
literal.push(literal, "…", literal, literal);
|
||||
for (const [value, expected] of [
|
||||
[tail, [42, "…"]],
|
||||
[middle, ["…", "…", 42]],
|
||||
[literal, ["…", "…", "…"]],
|
||||
[
|
||||
[tail, middle],
|
||||
[
|
||||
[42, "…"],
|
||||
["…", "…", 42],
|
||||
],
|
||||
],
|
||||
]) {
|
||||
const preview = formatToolDetail(value);
|
||||
expect(JSON.parse(preview.text)).toEqual(expected);
|
||||
expect(preview.truncated).toBe(true);
|
||||
expect(preview.text.length).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
}
|
||||
});
|
||||
|
||||
it("coalesces accessor, depth and budget tail markers", () => {
|
||||
const accessors = [42, 0, 0];
|
||||
for (const key of ["1", "2"]) {
|
||||
Object.defineProperty(accessors, key, {
|
||||
get() {
|
||||
throw new Error("must not read");
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(JSON.parse(formatToolDetail(accessors).text)).toEqual([42, "…"]);
|
||||
let deep: unknown = [1, 2, 3];
|
||||
for (let i = 0; i < 6; i++) deep = { child: deep };
|
||||
let parsed = JSON.parse(formatToolDetail(deep).text);
|
||||
for (let i = 0; i < 6; i++) parsed = parsed.child;
|
||||
expect(parsed).toEqual(["…"]);
|
||||
|
||||
const wide: unknown[] = [42];
|
||||
for (let i = 0; i < 4000; i++) wide.push(wide);
|
||||
expect(JSON.parse(formatToolDetail(wide).text)).toEqual([42, "…"]);
|
||||
});
|
||||
|
||||
// Text is already the tool's representation; parsing it again is lossy.
|
||||
it.each([
|
||||
"9223372036854775807",
|
||||
'{"run_id":9223372036854775807}',
|
||||
'[{"nested":{"run_id":-9223372036854775808}}]',
|
||||
"0.12345678901234567890123456789",
|
||||
'{"values":[1e400,1e-400,-0,9007199254740993]}',
|
||||
'{"run_id":1,"run_id":2}',
|
||||
'"quoted text"',
|
||||
'""',
|
||||
'"\\u0061\\n"',
|
||||
' { "run_id": 42 }\n',
|
||||
" ",
|
||||
])("preserves received text verbatim: %s", (text) => {
|
||||
expect(formatToolDetail(text)).toEqual({ text, truncated: false });
|
||||
});
|
||||
|
||||
it("keeps later array values after a cycle or inaccessible entry", () => {
|
||||
const cycle: unknown[] = [];
|
||||
cycle.push(cycle, { keep: 42 });
|
||||
expect(JSON.parse(formatToolDetail(cycle).text)).toEqual(["…", { keep: 42 }]);
|
||||
const values = [0, { keep: 42 }];
|
||||
Object.defineProperty(values, "0", {
|
||||
get() {
|
||||
throw new Error("must not read");
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(formatToolDetail(values).text)).toEqual([
|
||||
"…",
|
||||
{ keep: 42 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not split surrogate pairs at raw or structured text limits", () => {
|
||||
for (let padding = 0; padding < 24; padding++) {
|
||||
const text = "x".repeat(padding) + "😀".repeat(12000);
|
||||
for (const value of [text, { text }, [text]]) {
|
||||
const preview = formatToolDetail(value);
|
||||
const decoded =
|
||||
typeof value === "string" ? preview.text : JSON.parse(preview.text);
|
||||
const result =
|
||||
typeof decoded === "string"
|
||||
? decoded
|
||||
: Array.isArray(decoded)
|
||||
? decoded[0]
|
||||
: decoded.text;
|
||||
expect(result.isWellFormed()).toBe(true);
|
||||
expect(preview.text.length).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
expect(preview.truncated).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves representable siblings next to a depth-limited value", () => {
|
||||
let value: unknown = [{ deep: { tooDeep: true } }, 42];
|
||||
for (let i = 0; i < 5; i++) value = { child: value };
|
||||
let parsed = JSON.parse(formatToolDetail(value).text);
|
||||
for (let i = 0; i < 5; i++) parsed = parsed.child;
|
||||
expect(parsed).toEqual([{ deep: "…" }, 42]);
|
||||
});
|
||||
|
||||
it("checks bounded JSON and data fidelity across a deterministic mixed corpus", () => {
|
||||
let seed = 5309;
|
||||
const random = (max: number) => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return seed % max;
|
||||
};
|
||||
const strings = ["", "…", "key…", "__proto__", '"\\\n\t', "😀中文"];
|
||||
const generate = (depth: number): unknown => {
|
||||
switch (random(depth < 4 ? 7 : 4)) {
|
||||
case 0:
|
||||
return null;
|
||||
case 1:
|
||||
return random(2) === 1;
|
||||
case 2:
|
||||
return random(200000) - 100000;
|
||||
case 3:
|
||||
return strings[random(strings.length)]!.repeat(random(20));
|
||||
case 4:
|
||||
return Array.from({ length: random(10) }, () => generate(depth + 1));
|
||||
default:
|
||||
return Object.fromEntries(
|
||||
Array.from({ length: random(10) }, (_, i) => [
|
||||
strings[random(strings.length)]! + i,
|
||||
generate(depth + 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
};
|
||||
const check = (original: unknown, displayed: unknown) => {
|
||||
if (displayed === "…") return;
|
||||
if (typeof original === "string" && typeof displayed === "string") {
|
||||
expect(
|
||||
displayed === original ||
|
||||
(displayed.endsWith("…") &&
|
||||
original.startsWith(displayed.slice(0, -1))),
|
||||
).toBe(true);
|
||||
} else if (Array.isArray(original)) {
|
||||
expect(Array.isArray(displayed)).toBe(true);
|
||||
(displayed as unknown[]).forEach((child, i) => check(original[i], child));
|
||||
} else if (original !== null && typeof original === "object") {
|
||||
expect(displayed).not.toBeNull();
|
||||
for (const [key, child] of Object.entries(
|
||||
displayed as Record<string, unknown>,
|
||||
)) {
|
||||
if (key === "…" && child === "…") continue;
|
||||
expect(Object.prototype.hasOwnProperty.call(original, key)).toBe(true);
|
||||
check((original as Record<string, unknown>)[key], child);
|
||||
}
|
||||
} else expect(displayed).toEqual(original);
|
||||
};
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const value = { data: generate(0) };
|
||||
const preview = formatToolDetail(value);
|
||||
expect(preview.text.length).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
const parsed = JSON.parse(preview.text);
|
||||
check(value, parsed);
|
||||
if (!preview.truncated) expect(parsed).toEqual(value);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps structure and accurate truncation flags around the text boundary", () => {
|
||||
for (
|
||||
let size = TOOL_PREVIEW_LIMIT - 100;
|
||||
size <= TOOL_PREVIEW_LIMIT + 10;
|
||||
size++
|
||||
) {
|
||||
const raw = "x".repeat(size);
|
||||
const preview = formatToolDetail(raw);
|
||||
expect(preview.truncated).toBe(size > TOOL_PREVIEW_LIMIT);
|
||||
expect(preview.text.length).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
for (const value of [{ text: raw }, [raw], { nested: [raw] }]) {
|
||||
const structured = formatToolDetail(value);
|
||||
expect(() => JSON.parse(structured.text)).not.toThrow();
|
||||
expect(structured.text.length).toBeLessThanOrEqual(TOOL_PREVIEW_LIMIT);
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user