From 2f755969d3d6fdb92477efcc648e2e15fba035eb Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 7 Aug 2026 16:09:18 +0800 Subject: [PATCH] feat(frontend): add auto test functionality to debug drawer Introduce a new auto test mode in the debug drawer, allowing users to run automated tests with mock data. This includes the addition of AutoTestPanel, AutoTestLivePanel, and AutoTestRunBar components for managing test cases and displaying results. The debug mode can now switch between manual and auto testing, enhancing the debugging experience. Co-authored-by: Cursor --- .../assistant-editor/debug-auto-test.tsx | 1194 +++++++++++++++++ .../assistant-editor/debug-preview.tsx | 124 +- 2 files changed, 1292 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/assistant-editor/debug-auto-test.tsx diff --git a/frontend/src/components/assistant-editor/debug-auto-test.tsx b/frontend/src/components/assistant-editor/debug-auto-test.tsx new file mode 100644 index 0000000..cbf2a60 --- /dev/null +++ b/frontend/src/components/assistant-editor/debug-auto-test.tsx @@ -0,0 +1,1194 @@ +"use client"; + +/** + * 调试抽屉「自动测试」模式:选择用例 → 一键运行 → 原地看进度与结果。 + * MVP 用本地 mock 数据模拟跑测,不接后端;后续换成真实 API 时主要改这里。 + */ + +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + FlaskConical, + Play, + Square, + X, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { SearchInput } from "@/components/ui/search-input"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type TestCaseKind = + | "user_simulation" + | "tool_call" + | "fixed_dialogue" + | "single_turn"; + +export type ChatScriptTurn = { + role: "user" | "assistant"; + content: string; +}; + +export type ToolScriptResult = { + input: string; + expected: string; + actual: string; + actualArgs?: Record; + toolOk: boolean; + argsOk: boolean; +}; + +export type DebugTestCase = { + id: string; + name: string; + kind: TestCaseKind; + goal: string; + maxTurns: number; + /** 对话类用例的脚本;工具调用类可为空 */ + script: ChatScriptTurn[]; + toolResult?: ToolScriptResult; + /** mock:最终判定;failAtTurn 表示第几轮失败(1-based) */ + outcome: "pass" | "fail"; + failAtTurn?: number; +}; + +/** 测试集:Debug Drawer 只按「当前助手关联」浏览,不做管理 */ +export type DebugTestSuite = { + id: string; + name: string; + /** + * 关联助手 id。MVP 用 `"*"` 表示「当前正在调试的助手都可见」; + * 接上真实 API 后改成具体 assistantId 列表。 + */ + assistantIds: string[]; + caseIds: string[]; +}; + +export type AutoTestPhase = "idle" | "ready" | "running" | "passed" | "failed"; + +export type AutoTestLiveMessage = { + id: string; + role: "user" | "assistant"; + content: string; + /** 固定输入 / AI Simulator 进度角标 */ + progressLabel?: string; +}; + +export type AutoTestRunState = { + phase: AutoTestPhase; + selectedId: string | null; + runCount: "1" | "3" | "5"; + currentTurn: number; + maxTurns: number; + liveMessages: AutoTestLiveMessage[]; + toolResult: ToolScriptResult | null; + failAtTurn: number | null; +}; + +const KIND_LABEL: Record = { + user_simulation: "用户模拟", + tool_call: "工具调用", + fixed_dialogue: "固定对话", + single_turn: "单轮响应", +}; + +const RECENT_IDS = ["tc-dual-car", "tc-hello"]; + +/** 轻量占位用例:能选中浏览,点运行会快速 PASS(完整剧本见下方 runnable cases) */ +function stubCase( + id: string, + name: string, + kind: TestCaseKind, + goal: string, +): DebugTestCase { + return { + id, + name, + kind, + goal, + maxTurns: kind === "tool_call" || kind === "single_turn" ? 1 : 4, + outcome: "pass", + script: + kind === "tool_call" + ? [] + : [ + { role: "user", content: `(${name})开始` }, + { role: "assistant", content: "好的,已收到。" }, + ], + toolResult: + kind === "tool_call" + ? { + input: name, + expected: "ok", + actual: "ok", + toolOk: true, + argsOk: true, + } + : undefined, + }; +} + +// ─── Mock catalog ──────────────────────────────────────────────────────────── + +export const MOCK_TEST_CASES: DebugTestCase[] = [ + { + id: "tc-dual-car", + name: "正常双车事故", + kind: "user_simulation", + goal: "完成事故处理且不得转人工", + maxTurns: 10, + outcome: "pass", + script: [ + { role: "user", content: "我这里刚刚撞了一下。" }, + { role: "assistant", content: "请您简单描述一下事发经过。" }, + { + role: "user", + content: "我正常开着,对方突然变道,两车侧面蹭到一起。", + }, + { + role: "assistant", + content: "好的,请问有没有人员受伤?车辆能否正常移动?", + }, + { role: "user", content: "人都没事,车还能开,停在应急车道了。" }, + { + role: "assistant", + content: "请保持安全距离,我帮您记录事故信息并引导后续处理。", + }, + { role: "user", content: "好的,对方也同意了。" }, + { + role: "assistant", + content: "已记录双方信息。请按提示完成拍照取证,稍后会生成处理指引。", + }, + ], + }, + stubCase( + "tc-no-injury", + "无人伤流程", + "fixed_dialogue", + "无人伤场景下完成快处引导", + ), + { + id: "tc-injury-transfer", + name: "有人伤转人工", + kind: "tool_call", + goal: "检测到人伤后调用 transfer_to_human", + maxTurns: 1, + outcome: "pass", + script: [], + toolResult: { + input: "有人受伤", + expected: "transfer_to_human", + actual: "transfer_to_human", + actualArgs: { reason: "injury" }, + toolOk: true, + argsOk: true, + }, + }, + stubCase( + "tc-refuse-photo", + "拒绝拍照", + "user_simulation", + "用户拒绝拍照时仍能完成必要取证引导", + ), + { + id: "tc-hello", + name: "用户说“喂”", + kind: "single_turn", + goal: "礼貌回应并引导用户说明来意", + maxTurns: 1, + outcome: "pass", + script: [ + { role: "user", content: "喂" }, + { + role: "assistant", + content: "您好,我是事故处理助手。请问需要什么帮助?", + }, + ], + }, + { + id: "tc-fixed-claim", + name: "固定话术理赔引导", + kind: "fixed_dialogue", + goal: "按固定剧本完成理赔引导", + maxTurns: 5, + outcome: "fail", + failAtTurn: 5, + script: [ + { role: "user", content: "我想报案理赔。" }, + { role: "assistant", content: "好的,请问事故发生时间是?" }, + { role: "user", content: "今天下午三点左右。" }, + { role: "assistant", content: "地点呢?" }, + { role: "user", content: "环城高速出口附近。" }, + { + role: "assistant", + content: "抱歉,我没听清地点,请您再说一次。", + }, + ], + }, + { + id: "tc-noise", + name: "噪声打断恢复", + kind: "user_simulation", + goal: "噪声后仍能继续对话", + maxTurns: 6, + outcome: "pass", + script: [ + { role: "user", content: "喂?听得到吗?" }, + { role: "assistant", content: "听得到,请继续说。" }, + { role: "user", content: "(环境嘈杂)对方车窗没摇上…" }, + { + role: "assistant", + content: "抱歉刚才有些听不清,您是说对方车窗没有摇上吗?", + }, + ], + }, + stubCase( + "tc-mumble", + "含糊地点澄清", + "user_simulation", + "地点表述不清时主动澄清", + ), + stubCase( + "tc-empty-utterance", + "空输入等待", + "single_turn", + "空输入时不误触发工具", + ), + stubCase( + "tc-transfer-tool", + "主动转人工工具", + "tool_call", + "用户明确要求转人工时调用工具", + ), + stubCase( + "tc-end-call-tool", + "结束通话工具", + "tool_call", + "流程结束后调用结束通话", + ), + stubCase( + "tc-barge-in", + "用户打断播报", + "user_simulation", + "播报中打断后正确切换聆听", + ), + stubCase( + "tc-long-silence", + "长静音追问", + "user_simulation", + "长静音后温和追问一次", + ), +]; + +/** + * 当前助手关联的测试集(MVP mock)。 + * `"*"` = 任意正在调试的助手都可见,避免把「系统全部测试集」甩进 Drawer。 + */ +export const MOCK_TEST_SUITES: DebugTestSuite[] = [ + { + id: "suite-accident-basic", + name: "事故快处基础流程", + assistantIds: ["*"], + caseIds: [ + "tc-dual-car", + "tc-no-injury", + "tc-injury-transfer", + "tc-refuse-photo", + ], + }, + { + id: "suite-clarify", + name: "异常输入与澄清", + assistantIds: ["*"], + caseIds: [ + "tc-hello", + "tc-noise", + "tc-mumble", + "tc-empty-utterance", + "tc-fixed-claim", + ], + }, + { + id: "suite-tools", + name: "工具与系统联动", + assistantIds: ["*"], + caseIds: ["tc-injury-transfer", "tc-transfer-tool", "tc-end-call-tool"], + }, + { + id: "suite-realtime-voice", + name: "实时语音交互", + assistantIds: ["*"], + caseIds: ["tc-hello", "tc-noise", "tc-barge-in", "tc-long-silence"], + }, +]; + +function findCase(id: string | null): DebugTestCase | null { + if (!id) return null; + return MOCK_TEST_CASES.find((item) => item.id === id) ?? null; +} + +function findSuite(id: string | null): DebugTestSuite | null { + if (!id) return null; + return MOCK_TEST_SUITES.find((item) => item.id === id) ?? null; +} + +/** 只返回当前助手关联的测试集 */ +function suitesForAssistant(assistantId: string | null): DebugTestSuite[] { + return MOCK_TEST_SUITES.filter((suite) => { + if (suite.assistantIds.includes("*")) return true; + if (!assistantId) return false; + return suite.assistantIds.includes(assistantId); + }); +} + +function casesInSuites(suites: DebugTestSuite[]): DebugTestCase[] { + const seen = new Set(); + const cases: DebugTestCase[] = []; + for (const suite of suites) { + for (const caseId of suite.caseIds) { + if (seen.has(caseId)) continue; + seen.add(caseId); + const item = findCase(caseId); + if (item) cases.push(item); + } + } + return cases; +} + +function matchCaseQuery(item: DebugTestCase, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + return ( + item.name.toLowerCase().includes(q) || + KIND_LABEL[item.kind].toLowerCase().includes(q) + ); +} + +function progressLabelFor( + kind: TestCaseKind, + turnIndex: number, + maxTurns: number, +): string { + if (kind === "fixed_dialogue") { + return `固定输入 ${turnIndex} / ${maxTurns}`; + } + if (kind === "user_simulation") { + return `AI Simulator ${turnIndex} / ${maxTurns}`; + } + return `${turnIndex} / ${maxTurns}`; +} + +// ─── Mode tabs (手动 / 自动) ────────────────────────────────────────────────── + +export function DebugModeTabs({ + mode, + onChange, +}: { + mode: "manual" | "auto"; + onChange: (mode: "manual" | "auto") => void; +}) { + return ( + onChange(value as "manual" | "auto")} + className="w-full gap-0" + > + + 手动测试 + 自动测试 + + + ); +} + +// ─── Case summary (选中未开跑时居中展示) ───────────────────────────────────── + +export function AutoTestCaseSummary({ + testCase, +}: { + testCase: DebugTestCase; +}) { + return ( +
+
+
+ {testCase.name} +
+ + {KIND_LABEL[testCase.kind]} + +
+
+ ); +} + +// ─── Live transcript / tool result ─────────────────────────────────────────── + +export function AutoTestLivePanel({ + testCase, + state, +}: { + testCase: DebugTestCase | null; + state: AutoTestRunState; +}) { + const scrollRef = useRef(null); + const started = + state.phase === "running" || + state.phase === "passed" || + state.phase === "failed"; + + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [state.liveMessages, state.toolResult, state.phase]); + + if (!testCase) { + return ( +
+ +
+ 选择一个测试用例 +
+

+ 在底部运行条中选择用例,即可在这里查看目标与自动对话回放。 +

+
+ ); + } + + // 已选中、尚未开始:中间居中展示用例详情 + if (!started) { + return ; + } + + if (testCase.kind === "tool_call") { + const result = state.toolResult; + + if (state.phase === "running" && !result) { + return ( +
+ +

正在执行工具断言…

+
+ ); + } + + if (!result) { + return ( +
+

暂无工具调用结果

+
+ ); + } + + return ( +
+
+ + + +
+ + {result.toolOk ? : } + Tool + + + {result.argsOk ? : } + Arguments + +
+
+
+ ); + } + + if (state.liveMessages.length === 0) { + return ( +
+ +

正在开始自动对话…

+
+ ); + } + + return ( +
+
+ {state.liveMessages.map((message) => + message.role === "assistant" ? ( +
+ 助手 +
+ {message.content} +
+
+ ) : ( +
+ + AI 用户 + {message.progressLabel && ( + + {message.progressLabel} + + )} + +
+ {message.content} +
+
+ ), + )} +
+
+ ); +} + +function ToolResultBlock({ + label, + body, + mono = false, + json, +}: { + label: string; + body: string; + mono?: boolean; + json?: Record; +}) { + return ( +
+
{label}
+
+ {body} + {json && ( +
+            {JSON.stringify(json, null, 2)}
+          
+ )} +
+
+ ); +} + +// ─── Case picker popover ───────────────────────────────────────────────────── + +function CaseRowButton({ + item, + selected, + onSelect, +}: { + item: DebugTestCase; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function TestCasePickerPopover({ + open, + onOpenChange, + selectedId, + onSelect, + assistantId, + trigger, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedId: string | null; + onSelect: (id: string) => void; + assistantId: string | null; + trigger: React.ReactNode; +}) { + const [query, setQuery] = useState(""); + const [activeSuiteId, setActiveSuiteId] = useState(null); + + const suites = useMemo( + () => suitesForAssistant(assistantId), + [assistantId], + ); + const scopedCases = useMemo(() => casesInSuites(suites), [suites]); + const activeSuite = findSuite(activeSuiteId); + + const recent = useMemo(() => { + const scopedIds = new Set(scopedCases.map((item) => item.id)); + return RECENT_IDS.map((id) => findCase(id)).filter( + (item): item is DebugTestCase => + item !== null && scopedIds.has(item.id), + ); + }, [scopedCases]); + + const suiteCases = useMemo(() => { + if (!activeSuite) return []; + return activeSuite.caseIds + .map((id) => findCase(id)) + .filter((item): item is DebugTestCase => Boolean(item)) + .filter((item) => matchCaseQuery(item, query)); + }, [activeSuite, query]); + + const searchHits = useMemo(() => { + if (!query.trim()) return []; + return scopedCases.filter((item) => matchCaseQuery(item, query)); + }, [scopedCases, query]); + + function pickCase(id: string) { + onSelect(id); + onOpenChange(false); + } + + const inSuite = Boolean(activeSuite); + + return ( + { + onOpenChange(next); + if (!next) { + setQuery(""); + setActiveSuiteId(null); + } + }} + > + {trigger} + + {inSuite && activeSuite ? ( + <> + + +
+ {suiteCases.length === 0 ? ( +
+ 没有匹配的测试用例 +
+ ) : ( + suiteCases.map((item) => ( + pickCase(item.id)} + /> + )) + )} +
+ + ) : ( + <> +
+ 选择测试用例 +
+ +
+ {query.trim() ? ( +
+ {searchHits.length === 0 ? ( +
+ 没有匹配的测试用例 +
+ ) : ( + searchHits.map((item) => ( + pickCase(item.id)} + /> + )) + )} +
+ ) : ( + <> + {recent.length > 0 && ( +
+
+ 最近使用 +
+ {recent.map((item) => ( + pickCase(item.id)} + /> + ))} +
+ )} +
+
+ 测试集 +
+ {suites.length === 0 ? ( +
+ 当前助手还没有关联测试集 +
+ ) : ( + suites.map((suite) => ( + + )) + )} +
+ + )} +
+ + )} +
+
+ ); +} + +// ─── Bottom run bar ────────────────────────────────────────────────────────── + +export function AutoTestRunBar({ + state, + assistantId, + onSelectCase, + onClearCase, + onStart, + onStop, + onRerun, +}: { + state: AutoTestRunState; + assistantId: string | null; + onSelectCase: (id: string) => void; + onClearCase: () => void; + onStart: () => void; + onStop: () => void; + onRerun: () => void; +}) { + const [pickerOpen, setPickerOpen] = useState(false); + const testCase = findCase(state.selectedId); + + if (state.phase === "idle" || !testCase) { + return ( +
+ + + 选择测试用例… + + + } + /> + +
+ ); + } + + if (state.phase === "running") { + return ( +
+ + + {testCase.name} + + + + 运行中 + {testCase.kind !== "tool_call" && ( + + {state.currentTurn} / {state.maxTurns} + + )} + + +
+ ); + } + + if (state.phase === "passed" || state.phase === "failed") { + const passed = state.phase === "passed"; + return ( +
+ + {passed ? : } + + + {testCase.name} + + + {passed + ? `PASS · ${state.currentTurn}轮` + : `FAIL · 第${state.failAtTurn ?? state.currentTurn}轮`} + + +
+ ); + } + + // ready:左侧选用例 + 清除;右侧灰色开始按钮 + return ( +
+
+ + + + {testCase.name} + + + } + /> + +
+ +
+ ); +} + +// ─── Hook: run simulation ──────────────────────────────────────────────────── + +const INITIAL_RUN_STATE: AutoTestRunState = { + phase: "idle", + selectedId: null, + runCount: "1", + currentTurn: 0, + maxTurns: 0, + liveMessages: [], + toolResult: null, + failAtTurn: null, +}; + +export function useAutoTestRunner() { + const [state, setState] = useState(INITIAL_RUN_STATE); + const timersRef = useRef([]); + const stoppedRef = useRef(false); + + const clearTimers = useCallback(() => { + for (const id of timersRef.current) window.clearTimeout(id); + timersRef.current = []; + }, []); + + useEffect(() => () => clearTimers(), [clearTimers]); + + const selectCase = useCallback( + (id: string) => { + clearTimers(); + stoppedRef.current = true; + const testCase = findCase(id); + setState({ + ...INITIAL_RUN_STATE, + phase: "ready", + selectedId: id, + runCount: "1", + maxTurns: testCase?.maxTurns ?? 0, + }); + }, + [clearTimers], + ); + + const setRunCount = useCallback((runCount: "1" | "3" | "5") => { + setState((current) => ({ ...current, runCount })); + }, []); + + const stop = useCallback(() => { + stoppedRef.current = true; + clearTimers(); + setState((current) => ({ + ...current, + phase: current.selectedId ? "ready" : "idle", + currentTurn: 0, + liveMessages: [], + toolResult: null, + failAtTurn: null, + })); + }, [clearTimers]); + + const clearCase = useCallback(() => { + stoppedRef.current = true; + clearTimers(); + setState(INITIAL_RUN_STATE); + }, [clearTimers]); + + const start = useCallback(() => { + const testCase = findCase(state.selectedId); + if (!testCase) return; + + clearTimers(); + stoppedRef.current = false; + + setState((current) => ({ + ...current, + phase: "running", + currentTurn: 0, + maxTurns: testCase.maxTurns, + liveMessages: [], + toolResult: null, + failAtTurn: null, + })); + + // 工具调用:短暂“执行中”,再展示结果 + if (testCase.kind === "tool_call") { + const timer = window.setTimeout(() => { + if (stoppedRef.current) return; + const passed = testCase.outcome === "pass"; + setState((current) => ({ + ...current, + phase: passed ? "passed" : "failed", + currentTurn: 1, + toolResult: testCase.toolResult ?? null, + failAtTurn: passed ? null : 1, + })); + }, 900); + timersRef.current.push(timer); + return; + } + + const script = testCase.script; + const userTurns = script.filter((turn) => turn.role === "user").length; + const displayMax = Math.max(testCase.maxTurns, userTurns); + let userTurnCount = 0; + + script.forEach((turn, index) => { + const delay = 700 + index * 1100; + const timer = window.setTimeout(() => { + if (stoppedRef.current) return; + + if (turn.role === "user") userTurnCount += 1; + const nextTurn = + turn.role === "user" ? userTurnCount : userTurnCount; + const label = + turn.role === "user" + ? progressLabelFor(testCase.kind, nextTurn, displayMax) + : undefined; + + setState((current) => ({ + ...current, + currentTurn: nextTurn, + maxTurns: displayMax, + liveMessages: [ + ...current.liveMessages, + { + id: `auto-${index}`, + role: turn.role, + content: turn.content, + progressLabel: label, + }, + ], + })); + + const isLast = index === script.length - 1; + if (!isLast) return; + + const finishTimer = window.setTimeout(() => { + if (stoppedRef.current) return; + const passed = testCase.outcome === "pass"; + setState((current) => ({ + ...current, + phase: passed ? "passed" : "failed", + failAtTurn: passed + ? null + : (testCase.failAtTurn ?? nextTurn), + currentTurn: passed + ? nextTurn + : (testCase.failAtTurn ?? nextTurn), + })); + }, 500); + timersRef.current.push(finishTimer); + }, delay); + timersRef.current.push(timer); + }); + }, [clearTimers, state.selectedId]); + + const rerun = useCallback(() => { + start(); + }, [start]); + + const selectedCase = findCase(state.selectedId); + + return { + state, + selectedCase, + selectCase, + clearCase, + setRunCount, + start, + stop, + rerun, + }; +} diff --git a/frontend/src/components/assistant-editor/debug-preview.tsx b/frontend/src/components/assistant-editor/debug-preview.tsx index dd02552..7aad48e 100644 --- a/frontend/src/components/assistant-editor/debug-preview.tsx +++ b/frontend/src/components/assistant-editor/debug-preview.tsx @@ -23,6 +23,12 @@ import { X, } from "lucide-react"; +import { + AutoTestLivePanel, + AutoTestRunBar, + DebugModeTabs, + useAutoTestRunner, +} from "@/components/assistant-editor/debug-auto-test"; import { NetworkQualityIndicator } from "@/components/network-quality-indicator"; import { ClientMessageDialog } from "@/components/client-message-dialog"; import { AuraVisualizer } from "@/components/ui/aura-visualizer"; @@ -67,6 +73,7 @@ type VizStyle = "aura" | "nebula" | "bars" | "wave"; // 调试面板顶部主视图:聊天记录 / 视频流 type DebugView = "chat" | "video"; type DebugInputMode = "mic" | "text"; +type DebugMode = "manual" | "auto"; type PendingDebugImage = { file: File; previewUrl: string; @@ -175,6 +182,9 @@ export function DebugDrawer({ }) { const preview = useVoicePreview(assistantId, onNodeActive); const camera = useCameraPreview(); + const autoTest = useAutoTestRunner(); + const stopAutoTest = autoTest.stop; + const [debugMode, setDebugMode] = useState("manual"); const [showTranscript, setShowTranscript] = useState(false); const [vizStyle, setVizStyle] = useState("aura"); const [view, setView] = useState("chat"); @@ -218,6 +228,20 @@ export function DebugDrawer({ [camera, preview], ); + // 切到自动测试时停掉手动会话,避免两边抢占麦克风/对话区 + const handleModeChange = useCallback( + (mode: DebugMode) => { + setDebugMode(mode); + if (mode === "auto" && recording) { + preview.disconnect(); + } + if (mode === "manual") { + stopAutoTest(); + } + }, + [preview, recording, stopAutoTest], + ); + return ( ); } +function AutoTestPanel({ + assistantId, + autoTest, +}: { + assistantId: string | null; + autoTest: ReturnType; +}) { + const { + state, + selectedCase, + selectCase, + clearCase, + start, + stop, + rerun, + } = autoTest; + + return ( +
+
+ +
+
+ +
+
+ ); +} + function ClientToolsPopover({ tools }: { tools: ClientToolDefinition[] }) { const [copiedToolName, setCopiedToolName] = useState(null);