From 71223f21e6ac58707f8524f9ea82ec663162a065 Mon Sep 17 00:00:00 2001 From: 24baigei Date: Thu, 13 Aug 2026 21:24:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(chat):=20=E5=B7=A5=E5=85=B7=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=B1=95=E5=BC=80=E5=8C=BA=E4=B8=8D=E5=86=8D=E6=88=AA?= =?UTF-8?q?=E6=96=AD=EF=BC=8CMCP=20=E4=B8=8E=E9=95=BF=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=8F=AF=E5=AE=8C=E6=95=B4=E6=A0=B8=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 展开工具卡片后无法核对实际执行了什么命令:动态 MCP 工具的长参数被砍到 60 字符只剩省略号,嵌套对象与数组则被整个丢弃;展示投影层还有一层 800 字符上限;内置 Bash 的折叠行按固定 48 字符硬切。 - 动态 mcp_* 工具的参数改走完整 JSON,配 whitespace-pre-wrap 换行与限高 滚动,长命令无需横向拖动即可通读 - 通用工具移除 60 字符硬截断:短原始值保留紧凑网格并完整显示,出现长文本 或嵌套结构时整体落入完整 JSON,展开态不再不可逆丢内容 - 通用网格改为遍历展示投影,__toolApprovalSummary 等合成参数不再泄漏 - 展示投影的 800 字符上限提到 20000 字符并改为深度遍历,可覆盖嵌套在对象 或数组中的超大字符串,截断时显式标注原始长度 - 折叠摘要行的视觉省略交给 CSS truncate 按实际窗口宽度决定,DOM 文本与新增 的悬浮 title 各留 600 字符防御上限 - 抽出 isDynamicMcpToolName 收敛原先三处 mcp_ 前缀硬编码 新增 10 条回归测试覆盖参数完整性、合成参数过滤、超大 payload 上限与折叠行 边界。agent-gui 前端 1656/1661 通过(5 项失败在未改动的干净主干上同样失败), gateway WebUI 563/563 通过,两端 tsc --noEmit 均通过。 Closes #444 --- .../test/chat/tool-argument-display.test.mjs | 410 ++++++++++++++++++ .../chat/assistant-bubble/ToolCallItem.tsx | 23 +- .../assistant-bubble/ToolResultDisplay.tsx | 45 +- .../assistant-bubble/assistantBubbleUtils.ts | 3 +- .../src/lib/chat/assistantBubbleAdapter.ts | 1 + crates/agent-ui/src/lib/chat/uiMessages.ts | 38 +- 6 files changed, 500 insertions(+), 20 deletions(-) create mode 100644 crates/agent-gui/test/chat/tool-argument-display.test.mjs diff --git a/crates/agent-gui/test/chat/tool-argument-display.test.mjs b/crates/agent-gui/test/chat/tool-argument-display.test.mjs new file mode 100644 index 000000000..fef5cbbd4 --- /dev/null +++ b/crates/agent-gui/test/chat/tool-argument-display.test.mjs @@ -0,0 +1,410 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const rootDir = fileURLToPath(new URL("../..", import.meta.url)); +const baseLoader = createTsModuleLoader({ rootDir }); +const uiMessages = baseLoader.loadModule("@liveagent/ui/lib/chat/uiMessages.ts"); +const toolPreview = baseLoader.loadModule("@liveagent/ui/lib/chat/toolPreview.ts"); + +function createReactRenderer() { + const requireFromRoot = createRequire(path.join(rootDir, "package.json")); + return { + jsxRuntime: requireFromRoot("react/jsx-runtime"), + renderToStaticMarkup: requireFromRoot("react-dom/server").renderToStaticMarkup, + }; +} + +function NullComponent() { + return null; +} + +const realAdapterFunctions = { + deriveFileToolPreview() { + return null; + }, + isDynamicMcpToolName: uiMessages.isDynamicMcpToolName, + previewText: uiMessages.previewText, + safeStringify: uiMessages.safeStringify, + summarizeToolCall: uiMessages.summarizeToolCall, + toolCallArgsForDisplay: uiMessages.toolCallArgsForDisplay, + toolResultMessageToText() { + return ""; + }, +}; + +function createToolArgsRenderer() { + const { jsxRuntime, renderToStaticMarkup } = createReactRenderer(); + const loader = createTsModuleLoader({ + rootDir, + mocks: { + "react/jsx-runtime": jsxRuntime, + "@liveagent/ui/components/chat/EditDiffView": { + EditDiffView: NullComponent, + }, + "@liveagent/ui/components/chat/FileToolArgs": { + FileToolArgsDisplay: NullComponent, + }, + "@liveagent/ui/components/chat/ToolSurfaces": { + MetaTags({ tags }) { + return jsxRuntime.jsx("div", { + children: tags.map((tag) => `${tag.label}=${tag.value}`).join(" "), + }); + }, + PathDisplay({ path: filePath }) { + return jsxRuntime.jsx("span", { children: filePath }); + }, + ToolFactGrid({ tags }) { + return jsxRuntime.jsx("div", { + "data-kind": "fact-grid", + children: tags.map((tag) => `${tag.label}=${tag.value}`).join(" "), + }); + }, + ToolScrollablePre({ className, children }) { + return jsxRuntime.jsx("pre", { "data-kind": "raw-args", className, children }); + }, + ToolSurface({ children }) { + return jsxRuntime.jsx("section", { children }); + }, + ToolSurfaceLabel({ label }) { + return jsxRuntime.jsx("span", { children: label }); + }, + }, + "@liveagent/ui/components/Markdown": { + Markdown: NullComponent, + }, + "@liveagent/ui/lib/chat/assistantBubbleAdapter": realAdapterFunctions, + "../../IconSet": { + Search: NullComponent, + }, + "./assistantBubbleUtils": { + displayString(value) { + return typeof value === "string" ? value.trim() : ""; + }, + getBuiltinResultKind() { + return null; + }, + getStableValueSignature(value) { + return JSON.stringify(value); + }, + getSubagentTask() { + return ""; + }, + isSubagentCardToolCall() { + return false; + }, + shouldShowSubagentApplyStatus() { + return false; + }, + shouldShowSubagentCleanupStatus() { + return false; + }, + shouldShowSubagentWorktreeLocation() { + return false; + }, + }, + "./ToolImages": { + getToolResultImages() { + return []; + }, + ToolResultImagePreview: NullComponent, + }, + }, + }); + + const { ToolArgsDisplay } = loader.loadModule( + "@liveagent/ui/components/chat/assistant-bubble/ToolResultDisplay.tsx", + ); + + return (toolCall) => + renderToStaticMarkup(jsxRuntime.jsx(ToolArgsDisplay, { item: { toolCall } })); +} + +function createToolCallItemRenderer() { + const { jsxRuntime, renderToStaticMarkup } = createReactRenderer(); + const loader = createTsModuleLoader({ + rootDir, + mocks: { + "react/jsx-runtime": jsxRuntime, + "@liveagent/adapters/assistantBubble": { + readAskUserQuestionDeadline() { + return undefined; + }, + retainRunningToolContent: false, + submitAskUserQuestionAnswers() {}, + usePendingToolApproval() { + return null; + }, + }, + "@liveagent/ui/components/chat/AskUserQuestionCard": { + AskUserQuestionCard: NullComponent, + }, + "@liveagent/ui/components/chat/AssistantStatus": { + AssistantStatus({ children }) { + return jsxRuntime.jsx("span", { children }); + }, + }, + "@liveagent/ui/components/chat/FileChangeBadge": { + FileChangeBadge: NullComponent, + }, + "@liveagent/ui/components/chat/LazyCollapse": { + LazyCollapse({ open, children }) { + return open ? children() : null; + }, + }, + "@liveagent/ui/components/chat/ToolSurfaces": { + ToolScrollablePre({ children }) { + return jsxRuntime.jsx("pre", { children }); + }, + ToolSection({ children }) { + return jsxRuntime.jsx("section", { children }); + }, + }, + "@liveagent/ui/i18n/index": { + useLocale() { + return { + t(key) { + return key; + }, + }; + }, + }, + "@liveagent/ui/lib/chat/askUserQuestion": { + ASK_USER_QUESTION_TOOL_NAME: "AskUserQuestion", + parseAskUserQuestionResultDetails() { + return null; + }, + sanitizeAskUserQuestionItems() { + return []; + }, + }, + "@liveagent/ui/lib/chat/assistantBubbleAdapter": { + ...realAdapterFunctions, + deriveFileChangeStats() { + return undefined; + }, + FILE_TOOL_TEXT_FIELDS: toolPreview.FILE_TOOL_TEXT_FIELDS, + }, + "@liveagent/ui/lib/shared/utils": { + cn(...values) { + return values.filter(Boolean).join(" "); + }, + }, + "../../IconSet": { + ChevronRight: NullComponent, + }, + "./assistantBubbleUtils": { + areStableValuesEqual(left, right) { + return JSON.stringify(left) === JSON.stringify(right); + }, + getBuiltinResultKind() { + return null; + }, + getSubagentInlineSummary() { + return ""; + }, + getToolDisplayName(name) { + return name; + }, + getToolDisplayTitle(toolCall) { + return { name: toolCall.name, action: "" }; + }, + getToolMeta() { + return { Icon: NullComponent }; + }, + isBuiltinShareToolName() { + return false; + }, + isSubagentCardToolCall() { + return false; + }, + }, + "./ToolResultDisplay": { + ToolArgsDisplay: NullComponent, + ToolResultDisplay: NullComponent, + }, + }, + }); + + const { MemoToolCallItem } = loader.loadModule( + "@liveagent/ui/components/chat/assistant-bubble/ToolCallItem.tsx", + ); + + return (toolCall) => + renderToStaticMarkup(jsxRuntime.jsx(MemoToolCallItem, { item: { toolCall } })); +} + +test("isDynamicMcpToolName classifies dynamic MCP tool names", () => { + assert.equal(typeof uiMessages.isDynamicMcpToolName, "function"); + assert.equal(uiMessages.isDynamicMcpToolName("mcp_ssh_execute_command"), true); + assert.equal(uiMessages.isDynamicMcpToolName(" mcp_fs_read"), true); + assert.equal(uiMessages.isDynamicMcpToolName("McpManager"), false); + assert.equal(uiMessages.isDynamicMcpToolName("Bash"), false); +}); + +test("display args keep long values intact and strip synthetic keys", () => { + const cmdString = `command-${"x".repeat(1200)}-command-tail`; + const options = { + cwd: "/workspace", + env: ["A=1", "B=2"], + nested: { token: `nested-${"y".repeat(900)}-nested-tail`, retries: 3 }, + }; + + const display = uiMessages.toolCallArgsForDisplay({ + type: "toolCall", + id: "mcp-long-args", + name: "mcp_ssh_execute_command", + arguments: { + cmdString, + options, + __toolApprovalPending: true, + __toolApprovalSummary: "secret-approval-summary", + [toolPreview.LIVE_TOOL_PREVIEW_META_KEY]: { v: 2, progress: 1, fields: {} }, + }, + }); + + assert.equal(display.cmdString, cmdString); + assert.deepEqual(display.options, options); + assert.equal(Object.hasOwn(display, "__toolApprovalPending"), false); + assert.equal(Object.hasOwn(display, "__toolApprovalSummary"), false); + assert.equal(Object.hasOwn(display, toolPreview.LIVE_TOOL_PREVIEW_META_KEY), false); +}); + +test("display args cap pathological strings, including nested ones, with an explicit marker", () => { + const display = uiMessages.toolCallArgsForDisplay({ + type: "toolCall", + id: "mcp-huge-args", + name: "mcp_fs_write_file", + arguments: { + content: "x".repeat(120_000), + options: { inner: "y".repeat(30_000) }, + }, + }); + + assert.ok(display.content.startsWith("x".repeat(20_000))); + assert.ok(display.content.endsWith("(已截断,len=120000)")); + assert.ok(display.content.length < 121_000); + assert.ok(display.options.inner.startsWith("y".repeat(20_000))); + assert.ok(display.options.inner.endsWith("(已截断,len=30000)")); +}); + +test("expanded MCP arguments render complete long and nested values as wrapped scrollable JSON", () => { + const renderToolArgs = createToolArgsRenderer(); + const cmdString = `command-${"x".repeat(1200)}-command-tail`; + const nestedValue = `nested-${"y".repeat(900)}-nested-tail`; + const html = renderToolArgs({ + type: "toolCall", + id: "mcp-long-args", + name: "mcp_ssh_execute_command", + arguments: { + cmdString, + options: { nested: nestedValue, retries: 3 }, + __toolApprovalSummary: "secret-approval-summary", + }, + }); + + assert.ok(html.includes('data-kind="raw-args"')); + assert.ok(!html.includes('data-kind="fact-grid"')); + assert.ok(html.includes("whitespace-pre-wrap")); + assert.ok(html.includes(cmdString)); + assert.ok(html.includes(nestedValue)); + assert.ok(!html.includes("secret-approval-summary")); +}); + +test("generic arguments above the grid limit fall through to complete JSON", () => { + const renderToolArgs = createToolArgsRenderer(); + const note = `note-${"n".repeat(1000)}-note-tail`; + const html = renderToolArgs({ + type: "toolCall", + id: "generic-long-args", + name: "CustomAudit", + arguments: { note, retries: 2 }, + }); + + assert.ok(html.includes('data-kind="raw-args"')); + assert.ok(!html.includes('data-kind="fact-grid"')); + assert.ok(html.includes(note)); + assert.ok(!html.includes("…")); +}); + +test("generic arguments with nested objects fall through to complete JSON instead of dropping them", () => { + const renderToolArgs = createToolArgsRenderer(); + const html = renderToolArgs({ + type: "toolCall", + id: "generic-nested-args", + name: "CustomAudit", + arguments: { config: { region: "eu-central-1" }, note: "short-note" }, + }); + + assert.ok(html.includes('data-kind="raw-args"')); + assert.ok(html.includes("eu-central-1")); + assert.ok(html.includes("short-note")); +}); + +test("short generic arguments keep the compact grid, complete and without synthetic keys", () => { + const renderToolArgs = createToolArgsRenderer(); + const note = `note-${"n".repeat(150)}-note-tail`; + const html = renderToolArgs({ + type: "toolCall", + id: "generic-short-args", + name: "CustomAudit", + arguments: { + note, + enabled: true, + __toolApprovalSummary: "secret-approval-summary", + }, + }); + + assert.ok(html.includes('data-kind="fact-grid"')); + assert.ok(!html.includes('data-kind="raw-args"')); + assert.ok(html.includes(note)); + assert.ok(html.includes("enabled=true")); + assert.ok(!html.includes("secret-approval-summary")); + assert.ok(!html.includes("…")); +}); + +test("Bash collapsed summary keeps the full first line in DOM and title instead of a 48-char cut", () => { + const renderToolCallItem = createToolCallItemRenderer(); + const command = `echo ${"a".repeat(140)}-command-tail`; + const html = renderToolCallItem({ + type: "toolCall", + id: "bash-long-command", + name: "Bash", + arguments: { command }, + }); + + // Once in the hover title, once in the visible summary text. + assert.equal(html.split(command).length - 1, 2); + assert.ok(!html.includes("…")); +}); + +test("Bash collapsed summary bounds pathological single-line commands in DOM and title", () => { + const renderToolCallItem = createToolCallItemRenderer(); + const command = "b".repeat(5000); + const html = renderToolCallItem({ + type: "toolCall", + id: "bash-huge-command", + name: "Bash", + arguments: { command }, + }); + + assert.ok(html.includes(`${"b".repeat(600)}…`)); + assert.ok(!html.includes("b".repeat(601))); +}); + +test("Bash collapsed title carries the full multi-line command while the summary shows the first line", () => { + const renderToolCallItem = createToolCallItemRenderer(); + const html = renderToolCallItem({ + type: "toolCall", + id: "bash-multiline-command", + name: "Bash", + arguments: { command: "line-one-alpha\nline-two-beta" }, + }); + + assert.ok(html.includes("line-one-alpha")); + // The second line is reachable only through the hover title. + assert.ok(html.includes("line-two-beta")); +}); diff --git a/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx b/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx index fe0da8bdf..53bb09147 100644 --- a/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx +++ b/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx @@ -40,6 +40,17 @@ import { } from "./assistantBubbleUtils"; import { ToolArgsDisplay, ToolResultDisplay } from "./ToolResultDisplay"; +// 折叠摘要里行内命令的展示上限:远超任何实际窗口一行可容纳的字符数,视觉 +// 省略仍由 CSS truncate 决定;仅防御超长单行命令(如内联脚本)把常驻 DOM +// 与原生 title 撑爆。完整命令在展开区可查看。 +const INLINE_COMMAND_PREVIEW_MAX_CHARS = 600; + +function capInlineCommandPreview(text: string) { + return text.length > INLINE_COMMAND_PREVIEW_MAX_CHARS + ? `${text.slice(0, INLINE_COMMAND_PREVIEW_MAX_CHARS)}…` + : text; +} + function ToolCallItem({ item, isRunning, @@ -102,6 +113,11 @@ function ToolCallItem({ ? item.toolCall.arguments.command.trim() : ""; const firstLine = inlineCommand ? inlineCommand.split("\n")[0] : ""; + // 折叠行的行内命令:视觉截断交给 CSS(truncate 按实际可用宽度出省略号), + // 不再按固定字符数硬切(#444)。DOM 文本与原生 title 各留一个远超可视宽度 + // 的上限,防止超长单行命令把常驻摘要行与悬浮提示撑到不可用。 + const firstLinePreview = capInlineCommandPreview(firstLine); + const inlineCommandTitle = inlineCommand ? capInlineCommandPreview(inlineCommand) : ""; const toolArgsSummary = isRedactedToolContent || isBash || inlineCommand ? "" @@ -175,7 +191,7 @@ function ToolCallItem({ (styled per the block container) matches the summary text */}
{title.name} @@ -187,10 +203,9 @@ function ToolCallItem({ ) : null} - {firstLine ? ( + {firstLinePreview ? ( - ${" "} - {firstLine.length > 48 ? `${firstLine.slice(0, 48)}…` : firstLine} + $ {firstLinePreview} ) : toolArgsSummary ? ( {toolArgsSummary} diff --git a/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx b/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx index 60edd46ad..b1edde09e 100644 --- a/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx +++ b/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx @@ -16,6 +16,7 @@ import { type EditResultDetails, type GlobResultDetails, type GrepResultDetails, + isDynamicMcpToolName, type ListResultDetails, type McpManagerResultDetails, previewText, @@ -99,14 +100,25 @@ function buildPagedResultTags(params: { ]; } +// Longest string that still reads well inside a fact-grid cell (~3 wrapped +// lines); longer values switch the whole display to the complete JSON view. +const GENERIC_GRID_VALUE_MAX_CHARS = 200; + /** Extract tool-specific display info */ -function getToolDisplay(toolCall: { name: string; arguments?: Record }) { +function getToolDisplay(toolCall: ToolTraceItem["toolCall"]) { const args = toolCall.arguments || {}; const name = toolCall.name; const path = typeof args.path === "string" ? (args.path as string) : null; const pattern = typeof args.pattern === "string" ? (args.pattern as string) : null; const tags: MetaTag[] = []; + // Dynamic MCP tools carry arbitrary commands and nested payloads; their + // expanded view must show the complete (display-sanitized) arguments, not + // the primitive-only fact grid (#444). + if (isDynamicMcpToolName(name)) { + return { type: "raw" as const, path: null, pattern: null, tags }; + } + switch (name) { case "Read": if (typeof args.start_line === "number") @@ -203,13 +215,23 @@ function getToolDisplay(toolCall: { name: string; arguments?: Record