Skip to content

Commit 5992b2e

Browse files
authored
Merge pull request #6153 from SivanCola/fix/compact-tool-error-details
Compact tool error details / 折叠工具错误详情
2 parents b98d427 + bc556ce commit 5992b2e

9 files changed

Lines changed: 292 additions & 10 deletions

File tree

desktop/frontend/src/__tests__/recovery-quiet-notifications.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ ok(!appSource.includes("AcknowledgeTabRecovery"), "App does not expose recovery
3838
ok(!appSource.includes("OpenTabRecoveryParent"), "App does not expose recovery compare controls");
3939
ok(!appSource.includes("recovery.openOriginalFailed"), "App does not carry recovery compare failure text");
4040

41-
ok(controllerSource.includes("if (recoveryNoticeDedupeKey(rawText))"), "raw recovery notices are skipped before localization");
42-
ok(controllerSource.includes("if (recoveryNoticeDedupeKey(text))"), "localized recovery notices are skipped before rendering");
41+
ok(controllerSource.includes("function quietTranscriptNoticeKey"), "controller centralizes quiet transcript notices");
42+
ok(controllerSource.includes("if (quietTranscriptNoticeKey(rawText))"), "raw quiet notices are skipped before localization");
43+
ok(controllerSource.includes("if (quietTranscriptNoticeKey(text))"), "localized quiet notices are skipped before rendering");
4344

4445
const removedPromptKeys = [
4546
"recovery.open",
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Run: tsx src/__tests__/tool-card-error-details.test.tsx
2+
3+
import { JSDOM } from "jsdom";
4+
import React from "react";
5+
import { act } from "react";
6+
import { createRoot } from "react-dom/client";
7+
import { ToolCard } from "../components/ToolCard";
8+
import { LocaleProvider } from "../lib/i18n";
9+
import type { Item } from "../lib/useController";
10+
11+
type ToolItem = Extract<Item, { kind: "tool" }>;
12+
13+
let passed = 0;
14+
let failed = 0;
15+
16+
function ok(value: unknown, label: string) {
17+
if (value) {
18+
process.stdout.write(` PASS ${label}\n`);
19+
passed += 1;
20+
} else {
21+
process.stdout.write(` FAIL ${label}\n`);
22+
failed += 1;
23+
}
24+
}
25+
26+
function eq(actual: unknown, expected: unknown, label: string) {
27+
if (actual === expected) ok(true, label);
28+
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
29+
}
30+
31+
function flushTimers(): Promise<void> {
32+
return new Promise((resolve) => setTimeout(resolve, 0));
33+
}
34+
35+
function installDom() {
36+
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
37+
pretendToBeVisual: true,
38+
url: "http://localhost/",
39+
});
40+
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
41+
globalThis.window = dom.window as unknown as Window & typeof globalThis;
42+
globalThis.document = dom.window.document;
43+
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
44+
globalThis.Node = dom.window.Node;
45+
globalThis.Element = dom.window.Element;
46+
globalThis.HTMLElement = dom.window.HTMLElement;
47+
globalThis.Event = dom.window.Event;
48+
globalThis.MouseEvent = dom.window.MouseEvent;
49+
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
50+
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
51+
dom.window.matchMedia = () => ({
52+
matches: true,
53+
media: "(prefers-reduced-motion: reduce)",
54+
onchange: null,
55+
addListener: () => undefined,
56+
removeListener: () => undefined,
57+
addEventListener: () => undefined,
58+
removeEventListener: () => undefined,
59+
dispatchEvent: () => false,
60+
});
61+
return dom;
62+
}
63+
64+
console.log("\ntool card error details");
65+
66+
{
67+
const dom = installDom();
68+
const rootEl = document.getElementById("root");
69+
if (!rootEl) throw new Error("missing root");
70+
const root = createRoot(rootEl);
71+
const error = "evidence 1: verification command \"Select-String -Path @(...long...)\" has no matching successful receipt — cite the command exactly as it ran in the session; commands that ran: [\"unique-command-tail\"] — pick the matching one and retry complete_step";
72+
const item: ToolItem = {
73+
kind: "tool",
74+
id: "complete-step-error",
75+
name: "complete_step",
76+
args: "",
77+
readOnly: true,
78+
status: "error",
79+
output: `error: ${error}`,
80+
error,
81+
durationMs: 62,
82+
};
83+
84+
await act(async () => {
85+
root.render(
86+
React.createElement(LocaleProvider, null,
87+
React.createElement(ToolCard, { item }),
88+
),
89+
);
90+
await flushTimers();
91+
});
92+
93+
eq(document.querySelectorAll(".code-block").length, 0, "duplicate error output is not rendered as a code block");
94+
ok(document.querySelector(".tool__err-summary")?.textContent?.includes("verification command has no matching successful receipt"), "compact error summary is visible");
95+
ok(!document.querySelector(".tool__err-details"), "full error details are hidden by default");
96+
ok(!document.body.textContent?.includes("unique-command-tail"), "long receipt list is not visible before expansion");
97+
98+
const toggle = document.querySelector(".tool__err-toggle") as HTMLButtonElement | null;
99+
if (!toggle) throw new Error("error details toggle did not render");
100+
await act(async () => {
101+
toggle.click();
102+
await flushTimers();
103+
});
104+
105+
ok(document.querySelector(".tool__err-details")?.textContent?.includes("unique-command-tail"), "full error details render after expansion");
106+
107+
await act(async () => {
108+
root.unmount();
109+
});
110+
dom.window.close();
111+
}
112+
113+
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
114+
if (failed > 0) process.exit(1);

desktop/frontend/src/__tests__/use-controller-meta.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,16 +223,42 @@ console.log("\nuse controller meta");
223223
eq(ordinaryNotices.length, 2, "ordinary repeated notices remain visible");
224224
}
225225

226+
{
227+
const quietLifecycleMessages = [
228+
{ level: "info", text: "guardian enabled · model=guardian-test" },
229+
{ level: "warn", text: "2 MCP server(s) failed to start: fs, browser — run /mcp for details" },
230+
{ level: "warn", text: "mcp fs: stdio plugin \"fs\": command \"missing-fs\" not found on PATH" },
231+
{ level: "info", text: "settings applied: session refreshed after the lease was released" },
232+
{ level: "info", text: "plugin \"slowserver\" has been slow 3 startups in a row (last 30000ms, budget 1000ms); demoting to background startup this session" },
233+
] as const;
234+
let s = initialState;
235+
for (const message of quietLifecycleMessages) {
236+
s = reducer(s, { type: "event", e: { kind: "notice", level: message.level, text: message.text } });
237+
}
238+
eq(s.items.filter((item) => item.kind === "notice").length, 0, "background lifecycle notices stay silent in the live transcript");
239+
eq(s.seq, 0, "silent lifecycle notices do not consume sequence ids");
240+
241+
const userActionFailure = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "mcp connect: no configured MCP server named \"fs\"" } });
242+
const visibleNotices = userActionFailure.items.filter((item) => item.kind === "notice");
243+
eq(visibleNotices.length, 1, "user-triggered MCP failures remain visible");
244+
eq(visibleNotices[0]?.kind === "notice" && visibleNotices[0].text, "mcp connect: no configured MCP server named \"fs\"", "visible MCP failure keeps its text");
245+
}
246+
226247
{
227248
const history: HistoryMessage[] = [
228249
{ role: "notice", level: "warn", content: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
229250
{ role: "notice", level: "warn", content: "repeated save conflicts were detected; saved the current conflict copy in place" },
251+
{ role: "notice", level: "info", content: "guardian enabled · model=guardian-test" },
252+
{ role: "notice", level: "warn", content: "1 MCP server(s) failed to start: fs — run /mcp for details" },
253+
{ role: "notice", level: "info", content: "settings applied: session refreshed after the lease was released" },
230254
{ role: "user", content: "continue" },
231255
];
232256
const hydrated = historyMessagesToItems(history, "h");
233257
const recoveryNotices = hydrated.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
258+
const lifecycleNotices = hydrated.items.filter((item) => item.kind === "notice");
234259
const users = hydrated.items.filter((item) => item.kind === "user");
235260
eq(recoveryNotices.length, 0, "recovery conflict notices stay silent when hydrating history");
261+
eq(lifecycleNotices.length, 0, "background lifecycle notices stay silent when hydrating history");
236262
eq(users[0]?.kind === "user" && users[0].id, "h0", "silent history notices keep later item ids compact");
237263
eq(hydrated.seq, 1, "silent history notices do not inflate the hydrated sequence");
238264
}

desktop/frontend/src/components/ToolCard.tsx

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const SUBAGENT_TOOLS = new Set(["task", "run_skill", "explore", "research", "rev
1616

1717
/** Lines shown by default in a shell output block before the "show all" button. */
1818
const SHELL_PREVIEW_LINES = 10;
19+
const ERROR_SUMMARY_MAX_CHARS = 140;
20+
const ERROR_DETAILS_THRESHOLD = 220;
1921

2022
function pretty(json: string): string {
2123
try {
@@ -30,6 +32,41 @@ function formatToolDuration(ms?: number): string {
3032
return `${Math.round(ms)} ms`;
3133
}
3234

35+
function normalizeErrorText(text: string): string {
36+
return text.replace(/\r\n/g, "\n").trim();
37+
}
38+
39+
function withoutErrorPrefix(text: string): string {
40+
return normalizeErrorText(text).replace(/^error:\s*/i, "");
41+
}
42+
43+
function toolOutputDuplicatesError(output: string | undefined, error: string | undefined): boolean {
44+
if (!output || !error) return false;
45+
const normalizedOutput = normalizeErrorText(output);
46+
const normalizedError = normalizeErrorText(error);
47+
if (!normalizedOutput || !normalizedError) return false;
48+
return normalizedOutput === normalizedError || withoutErrorPrefix(normalizedOutput) === withoutErrorPrefix(normalizedError);
49+
}
50+
51+
function summarizeToolError(error: string, receiptMismatchText: string): string {
52+
const text = withoutErrorPrefix(error);
53+
if (!text) return "";
54+
if (/has no matching successful receipt/i.test(text)) {
55+
return receiptMismatchText;
56+
}
57+
const firstLine = text.split("\n")[0]?.trim() ?? "";
58+
if (firstLine.length <= ERROR_SUMMARY_MAX_CHARS) return firstLine;
59+
return `${firstLine.slice(0, ERROR_SUMMARY_MAX_CHARS - 1)}…`;
60+
}
61+
62+
function errorNeedsDetails(error: string, summary: string): boolean {
63+
const normalizedError = withoutErrorPrefix(error);
64+
if (!normalizedError) return false;
65+
return normalizedError.includes("\n") ||
66+
normalizedError.length > ERROR_DETAILS_THRESHOLD ||
67+
(summary !== "" && normalizedError !== summary);
68+
}
69+
3370
/** Returns the first n lines of text and the total line count. */
3471
function splitPreview(text: string, n: number): { preview: string; total: number; hasMore: boolean } {
3572
const lines = text.split("\n");
@@ -60,12 +97,14 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
6097
const openRef = useRef(open);
6198
openRef.current = open;
6299
const [showAll, setShowAll] = useState(false);
100+
const [showErrorDetails, setShowErrorDetails] = useState(false);
63101
// Lazy-load full tool data from the backend when the card is expanded and
64102
// the in-memory copy was archived for memory efficiency.
65103
const [fullData, setFullData] = useState<{ args: string; output?: string } | null>(null);
66104
const archivedWithoutFullData = Boolean(item.dataArchived && !fullData);
67105
const effectiveArgs = archivedWithoutFullData ? "" : fullData?.args ?? item.args;
68106
const effectiveOutput = fullData?.output ?? item.output;
107+
const displayOutput = toolOutputDuplicatesError(effectiveOutput, item.error) ? undefined : effectiveOutput;
69108
const previewDiff = item.fileDiff?.diff ? item.fileDiff : undefined;
70109
const diffs = previewDiff || archivedWithoutFullData ? [] : diffsFor(item.name, effectiveArgs);
71110
const subject = fullData ? subjectOf(item.name, effectiveArgs) : item.subject || subjectOf(item.name, effectiveArgs);
@@ -78,12 +117,15 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
78117
// else folds its args/output away by default. Open while running so the
79118
// user sees progress; closed by default once settled.
80119
const hasArchivedOnDemandBody = Boolean(item.dataArchived && tabId);
81-
const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!effectiveOutput || hasArchivedOnDemandBody);
120+
const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!displayOutput || hasArchivedOnDemandBody);
82121

83122
// Shell output: split into preview + "show all" toggle.
84-
const shellOutput = item.isShell && effectiveOutput ? effectiveOutput : null;
123+
const shellOutput = item.isShell && displayOutput ? displayOutput : null;
85124
const shellPreview = shellOutput ? splitPreview(shellOutput, SHELL_PREVIEW_LINES) : null;
86125
const hasBody = Boolean(previewDiff || diffs.length || hasNested || shellPreview || (!shellPreview && hasArgsOrOutput) || item.error);
126+
const errorText = item.error ? normalizeErrorText(item.error) : "";
127+
const errorSummary = errorText ? summarizeToolError(errorText, t("tool.errorReceiptMismatch")) : "";
128+
const hasErrorDetails = errorText ? errorNeedsDetails(errorText, errorSummary) : false;
87129
useEffect(() => {
88130
if (!open || !item.dataArchived || fullData || !tabId) return;
89131
let cancelled = false;
@@ -111,7 +153,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
111153
item.readOnly && !hasNested && item.status !== "error" && item.status !== "stopped";
112154

113155
const duration = item.status === "running" ? "" : formatToolDuration(item.durationMs);
114-
const summary = item.status === "running" ? "" : item.summary || summarizeFileDiff(item.fileDiff) || (archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, effectiveOutput, item.error));
156+
const summary = item.status === "running" ? "" : item.summary || summarizeFileDiff(item.fileDiff) || (item.error ? errorSummary : archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, displayOutput, item.error));
115157

116158
// GSAP-driven collapse/expand for tool body
117159
const toolBodyRef = useRef<HTMLDivElement>(null);
@@ -207,16 +249,36 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
207249
{!shellPreview && hasArgsOrOutput && (
208250
<>
209251
{effectiveArgs && <CodeViewer value={pretty(effectiveArgs)} language="json" maxHeight={180} />}
210-
{effectiveOutput && (
252+
{displayOutput && (
211253
<>
212-
<CodeViewer value={effectiveOutput} maxHeight={280} />
254+
<CodeViewer value={displayOutput} maxHeight={280} />
213255
{item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
214256
</>
215257
)}
216258
</>
217259
)}
218260

219-
{item.error && <div className="tool__err">{item.error}</div>}
261+
{errorText && (
262+
<div className={`tool__err${hasErrorDetails ? " tool__err--compact" : ""}`}>
263+
{hasErrorDetails ? (
264+
<>
265+
<div className="tool__err-summary">{errorSummary || t("tool.error")}</div>
266+
<button
267+
type="button"
268+
className="tool__err-toggle"
269+
onClick={() => setShowErrorDetails((value) => !value)}
270+
aria-expanded={showErrorDetails}
271+
>
272+
<ChevronRight className={`tool__err-toggle-icon${showErrorDetails ? " tool__err-toggle-icon--open" : ""}`} size={12} aria-hidden="true" />
273+
<span>{showErrorDetails ? t("tool.hideErrorDetails") : t("tool.showErrorDetails")}</span>
274+
</button>
275+
{showErrorDetails && <div className="tool__err-details">{errorText}</div>}
276+
</>
277+
) : (
278+
errorText
279+
)}
280+
</div>
281+
)}
220282
</div>
221283
</div>
222284
);

desktop/frontend/src/lib/useController.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,12 +1064,39 @@ function recoveryNoticeDedupeKey(text: string): string {
10641064
return "";
10651065
}
10661066

1067+
function quietTranscriptNoticeKey(text: string): string {
1068+
const recovery = recoveryNoticeDedupeKey(text);
1069+
if (recovery) return recovery;
1070+
1071+
const msg = text.trim();
1072+
if (/^guardian enabled · model=.+$/i.test(msg)) {
1073+
return "startup:guardian-enabled";
1074+
}
1075+
if (/^\d+ MCP server\(s\) failed to start: .+ \u2014 run \/mcp for details$/i.test(msg)) {
1076+
return "startup:mcp-failures";
1077+
}
1078+
const directMCPFailure = /^mcp\s+([A-Za-z0-9._-]+):\s+.+$/i.exec(msg);
1079+
if (directMCPFailure) {
1080+
const name = directMCPFailure[1].toLowerCase();
1081+
if (!["add", "auth", "config", "connect", "import", "mode", "remove"].includes(name)) {
1082+
return "startup:mcp-failure";
1083+
}
1084+
}
1085+
if (/^plugin ".+" has been slow \d+ startups in a row \(last \d+ms, budget \d+ms\); demoting to background startup this session$/i.test(msg)) {
1086+
return "startup:plugin-demote";
1087+
}
1088+
if (/^.+ applied: session refreshed after the lease was released$/i.test(msg)) {
1089+
return "settings:deferred-refresh-applied";
1090+
}
1091+
return "";
1092+
}
1093+
10671094
function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string): { items: Item[]; seq: number } {
1068-
if (recoveryNoticeDedupeKey(rawText)) {
1095+
if (quietTranscriptNoticeKey(rawText)) {
10691096
return { items, seq };
10701097
}
10711098
const text = localizedBackendNoticeText(rawText);
1072-
if (recoveryNoticeDedupeKey(text)) {
1099+
if (quietTranscriptNoticeKey(text)) {
10731100
return { items, seq };
10741101
}
10751102
return { items: [...items, { kind: "notice", id, level, text }], seq: seq + 1 };

desktop/frontend/src/locales/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1916,8 +1916,12 @@ export const en = {
19161916
// tool card summaries
19171917
"tool.stepOne": "{n} step",
19181918
"tool.stepOther": "{n} steps",
1919+
"tool.error": "error",
1920+
"tool.errorReceiptMismatch": "verification command has no matching successful receipt",
19191921
"tool.truncated": "output truncated",
19201922
"tool.showAllLines": "show all {n} lines",
1923+
"tool.showErrorDetails": "show error details",
1924+
"tool.hideErrorDetails": "hide error details",
19211925
"tool.readCount": "Read {n} files",
19221926
"tool.searchCount": "Search {n} files",
19231927
"tool.otherReadCount": "{n} read calls",

desktop/frontend/src/locales/zh-TW.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,8 +1252,12 @@ export const zhTW: Record<DictKey, string> = {
12521252
// 工具卡片摘要
12531253
"tool.stepOne": "{n} 步",
12541254
"tool.stepOther": "{n} 步",
1255+
"tool.error": "錯誤",
1256+
"tool.errorReceiptMismatch": "證據命令沒有匹配的成功執行記錄",
12551257
"tool.truncated": "輸出已截斷",
12561258
"tool.showAllLines": "顯示全部 {n} 行",
1259+
"tool.showErrorDetails": "顯示錯誤詳情",
1260+
"tool.hideErrorDetails": "隱藏錯誤詳情",
12571261
"tool.lineOne": "{n} 行",
12581262
"tool.lineOther": "{n} 行",
12591263
"tool.matchOne": "{n} 處匹配",

desktop/frontend/src/locales/zh.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1918,8 +1918,12 @@ export const zh: Record<DictKey, string> = {
19181918
// 工具卡片摘要
19191919
"tool.stepOne": "{n} 步",
19201920
"tool.stepOther": "{n} 步",
1921+
"tool.error": "错误",
1922+
"tool.errorReceiptMismatch": "证据命令没有匹配的成功执行记录",
19211923
"tool.truncated": "输出已截断",
19221924
"tool.showAllLines": "显示全部 {n} 行",
1925+
"tool.showErrorDetails": "显示错误详情",
1926+
"tool.hideErrorDetails": "隐藏错误详情",
19231927
"tool.readCount": "已读 {n} 个文件",
19241928
"tool.searchCount": "搜索 {n} 个文件",
19251929
"tool.otherReadCount": "{n} 个只读调用",

0 commit comments

Comments
 (0)