- 新增 CORE_TOOLS 白名单常量(31 个核心工具),重构 isDeferredTool 为白名单制判定 - 新建 TF-IDF 工具索引模块(toolIndex.ts),复用 localSearch.ts 算法函数 - 新建 ExecuteTool 跨 API provider 统一工具执行入口 - 增强 ToolSearchTool:TF-IDF 搜索路径、discover: 模式、并行搜索合并、文本模式回退 - 新增 27 个单元测试,precheck 零错误通过(4108 tests pass) Co-Authored-By: glm-5.1[1m] <zai-org@claude-code-best.win>
31 KiB
Tool Search 执行计划(二)— 集成层
目标: 将基础设施层的组件集成到系统中——系统提示词增强、工具注册、预取管道、用户推荐 UI
技术栈: TypeScript, React (Ink), Bun, Zod
设计文档: spec/feature_20260508_F001_tool-search/spec-design.md
前置: spec-plan-1.md(Task 1-4)已完成
改动总览
- 在系统提示词添加 ToolSearch + ExecuteTool 引导指令,tools.ts 注册 ExecuteTool,toolSearch.ts 更新过时注释;新建预取管道 prefetch.ts 集成到 attachments.ts 和 query.ts(复用 skill prefetch 模式);新建 ToolSearchHint.tsx Ink 组件集成到 REPL
- Task 5(系统提示词与注册)是 Task 6/7 的前置;Task 6(预取管道)被 Task 7(UI)依赖
- 关键决策:预取管道完全复用 skill prefetch 的触发/消费模式;UI 组件参考 PluginHintMenu 模式
Task 0: 环境准备(轻量)
背景: Plan 1 的环境验证已完成,此处仅需确认 Plan 1 的产出文件可用。
执行步骤:
- 确认 Plan 1 产出文件存在
ls src/constants/tools.ts src/services/toolSearch/toolIndex.ts packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts 2>&1- 预期: 所有文件存在
检查步骤:
- Plan 1 核心常量可被引用
grep "CORE_TOOLS" src/constants/tools.ts | head -3- 预期: 输出包含 CORE_TOOLS 定义
Task 5: 系统提示词与工具注册
背景:
[业务语境] — 本 Task 将 Task 3 创建的 ExecuteTool 注册到系统工具池中,并在系统提示词中添加 ToolSearch + ExecuteTool 的使用引导,确保模型知道如何发现和调用延迟工具。
[修改原因] — 当前系统提示词(L192)仅提到"延迟工具必须通过 ToolSearch 或 DiscoverSkills 加载",缺少 ExecuteTool 的引导。src/tools.ts 的 getAllBaseTools() 中未注册 ExecuteTool。src/utils/toolSearch.ts 的 isToolSearchEnabled() 和 isToolSearchEnabledOptimistic() 内部已通过 isDeferredTool 间接使用 CORE_TOOLS(Task 1 重构后),需确认无遗留的 shouldDefer 直接引用。
[上下游影响] — 本 Task 依赖 Task 1(CORE_TOOLS、isDeferredTool 白名单制)和 Task 3(ExecuteTool 工具包创建完成)。本 Task 的输出被 Task 6(预取管道)和 Task 7(用户推荐 UI)依赖。
涉及文件:
- 修改:
src/constants/prompts.ts - 修改:
src/tools.ts - 修改:
src/utils/toolSearch.ts
执行步骤:
-
在
src/constants/prompts.ts中添加 ToolSearch + ExecuteTool 引导指令到系统提示词- 位置:
src/constants/prompts.ts的getSimpleSystemSection()函数内,在 L192 的延迟工具说明条目之后 - 当前 L192 内容为:
`Your visible tool list is partial by design — many tools (deferred tools, skills, MCP resources) must be loaded via ToolSearch or DiscoverSkills before you can call them. Before telling the user that a capability is unavailable, search for a tool or skill that covers it. Only state something is unavailable after the search returns no match.`, - 在此条目之后(L193 之前)插入新条目:
`When you need a capability that isn't in your available tools, use ToolSearch to discover and load it. ToolSearch can find all deferred tools by keyword or task description. After discovering a tool, use ExecuteTool to invoke it with the appropriate parameters. Common deferred tools include: CronTools (scheduling), WorktreeTools (git isolation), SnipTool (context management), DiscoverSkills (skill search), MCP resource tools, and many more. Always search first rather than assuming a capability is unavailable.`, - 在文件顶部 import 区域新增:
import { EXECUTE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExecuteTool/constants.js' - 注意:
TOOL_SEARCH_TOOL_NAME已通过src/constants/tools.ts的 import 链路导入(L25import { TOOL_SEARCH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'),无需重复导入。但需在prompts.ts中新增EXECUTE_TOOL_NAME的 import(当前文件中无此 import,经 grep 确认)。 - 原因: 模型需要明确知道 ExecuteTool 的存在和用法,否则发现延迟工具后不知道如何调用
- 位置:
-
在
src/tools.ts的getAllBaseTools()中注册 ExecuteTool- 位置:
src/tools.ts的getAllBaseTools()函数内,在 L272...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : [])之后 - 在文件顶部 import 区域(L84 附近,ToolSearchTool import 之后)新增:
import { ExecuteTool } from '@claude-code-best/builtin-tools/tools/ExecuteTool/ExecuteTool.js' - 将 L272:
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []), - 修改为:
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool, ExecuteTool] : []), - 原因: ExecuteTool 与 ToolSearchTool 联动启用,在相同条件块中注册确保两者同时可用或同时不可用
- 位置:
-
在
src/utils/toolSearch.ts中更新模块文档注释,移除过时的shouldDefer引用- 位置:
src/utils/toolSearch.ts文件顶部模块文档注释(L1-L7) - 当前 L4 内容为:
- 位置:
`
- When enabled, deferred tools (MCP and shouldDefer tools) are sent with
- defer_loading: true and discovered via ToolSearchTool rather than being
- loaded upfront.
- 修改为:
```
`
* When enabled, deferred tools (all non-core tools) are sent with
* defer_loading: true and discovered via ToolSearchTool rather than being
* loaded upfront. Core tools are defined in CORE_TOOLS (src/constants/tools.ts).
- 位置:
src/utils/toolSearch.ts的ToolSearchMode类型文档注释(L155-L156) - 当前内容为:
`
- Tool search mode. Determines how deferrable tools (MCP + shouldDefer) are
- surfaced:
- 修改为:
```
`
* Tool search mode. Determines how deferred tools (all non-core tools)
* are surfaced:
- 位置:
src/utils/toolSearch.ts的getToolSearchMode()函数文档注释(L170) - 当前内容为:
`
- (unset) tst (default: always defer MCP and shouldDefer tools)
- 修改为:
```
`
* (unset) tst (default: always defer non-core tools)
- 位置:
src/utils/toolSearch.ts的getToolSearchMode()函数末尾 return 注释(L197) - 当前内容为:
return 'tst' // default: always defer MCP and shouldDefer tools ```
- 修改为:
return 'tst' // default: always defer non-core tools ```
-
注意:
shouldDefer在此文件中仅出现在注释中(L4, L155, L170, L197),无任何运行时引用。isDeferredTool函数从@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js导入(L24),Task 1 已将其重构为白名单制,此处无需修改函数调用。 -
原因: Task 1 将
isDeferredTool重构为白名单制后,shouldDefer概念已过时。更新注释保持文档与实现一致。 -
为 Task 5 的三个修改点编写单元测试
- 测试文件:
src/__tests__/toolSearchIntegration.test.ts(新建) - 测试场景:
getSystemPrompt包含 ExecuteTool 引导: 调用getSystemPrompt(mockTools, model)后,结果字符串中包含 "ExecuteTool" 和 "ToolSearch" 关键词getAllBaseTools包含 ExecuteTool 当 tool search 启用时: mockisToolSearchEnabledOptimistic返回true,验证getAllBaseTools()返回的工具列表中包含name: 'ExecuteTool'的工具getAllBaseTools不包含 ExecuteTool 当 tool search 禁用时: mockisToolSearchEnabledOptimistic返回false,验证getAllBaseTools()返回的工具列表中不包含name: 'ExecuteTool'的工具getAllBaseTools中 ExecuteTool 紧随 ToolSearchTool: 验证在 tool search 启用时,ExecuteTool 在工具列表中的位置紧跟 ToolSearchTool
- Mock 策略: 使用
bun:test的mock函数 mocksrc/utils/toolSearch.js的isToolSearchEnabledOptimistic - 运行命令:
bun test src/__tests__/toolSearchIntegration.test.ts - 预期: 所有测试通过
- 测试文件:
检查步骤:
-
验证系统提示词包含 ExecuteTool 引导
grep -n "ExecuteTool" src/constants/prompts.ts- 预期: 至少 2 行(import + 引导文本)
-
验证 ExecuteTool 已注册到 getAllBaseTools
grep -n "ExecuteTool" src/tools.ts- 预期: 至少 2 行(import + 注册)
-
验证 ExecuteTool 与 ToolSearchTool 在同一条件块中注册
grep -A1 "isToolSearchEnabledOptimistic" src/tools.ts | grep -c "ExecuteTool"- 预期: 输出 1(ExecuteTool 在 isToolSearchEnabledOptimistic 条件块中)
-
验证 toolSearch.ts 中无运行时 shouldDefer 引用(仅注释)
grep -n "shouldDefer" src/utils/toolSearch.ts- 预期: 无输出或仅在注释中出现
-
验证 TypeScript 编译无错误
bunx tsc --noEmit --pretty 2>&1 | head -30- 预期: 无新增类型错误
-
运行新增单元测试
bun test src/__tests__/toolSearchIntegration.test.ts- 预期: 所有测试通过
-
验证现有 tools.test.ts 未回归
bun test src/__tests__/tools.test.ts- 预期: 所有测试通过
Task 6: 预取管道
背景:
[业务语境] — 本 Task 实现工具搜索预取管道,在用户输入后异步触发 TF-IDF 工具搜索,将推荐结果以 attachment 消息注入 API 请求,使模型在每轮对话中自动获得最相关的延迟工具提示。
[修改原因] — 当前项目仅实现了 skill 搜索的预取管道(skillSearch/prefetch.ts),缺少工具维度的预取。工具预取需复用 skill prefetch 的集成模式(turn-0 阻塞式 + inter-turn 异步式),但使用独立的 attachment type(tool_discovery)和独立的搜索函数(toolIndex.searchTools)。
[上下游影响] — 本 Task 依赖 Task 2(toolIndex.ts 的 getToolIndex 和 searchTools)。本 Task 的输出(prefetch.ts 模块和集成代码)被 Task 7(用户推荐 UI)间接依赖,UI 组件需要消费预取结果来渲染推荐提示条。
涉及文件:
- 新建:
src/services/toolSearch/prefetch.ts - 修改:
src/utils/attachments.ts - 修改:
src/query.ts
执行步骤:
-
新建
src/services/toolSearch/prefetch.ts,定义ToolDiscoveryResult类型和tool_discoveryattachment 构建函数- 位置: 新建文件
src/services/toolSearch/prefetch.ts,文件开头 - 导入依赖:
import type { Attachment } from '../../utils/attachments.js' import type { Message } from '../../types/message.js' import type { Tool } from '../../Tool.js' import { getToolIndex, searchTools } from './toolIndex.js' import type { ToolSearchResult } from './toolIndex.js' import { logForDebugging } from '../../utils/debug.js' - 定义
ToolDiscoveryResult类型:export type ToolDiscoveryResult = { name: string description: string searchHint: string | undefined score: number isMcp: boolean isDeferred: boolean inputSchema: object | undefined } - 定义
buildToolDiscoveryAttachment函数:function buildToolDiscoveryAttachment( tools: ToolDiscoveryResult[], trigger: 'assistant_turn' | 'user_input', queryText: string, durationMs: number, indexSize: number, ): Attachment { return { type: 'tool_discovery', tools, trigger, queryText: queryText.slice(0, 200), durationMs, indexSize, } as Attachment } - 原因:
tool_discovery作为独立的 attachment type 与skill_discovery并列,数据结构不同(工具无shortId/autoLoaded/content/path/gap,增加searchHint/isMcp/isDeferred/inputSchema),不能复用skill_discovery类型
- 位置: 新建文件
-
实现
startToolSearchPrefetch异步预取函数 — inter-turn 场景,在 query loop 中异步触发- 位置:
src/services/toolSearch/prefetch.ts,在buildToolDiscoveryAttachment之后 - 函数签名:
export async function startToolSearchPrefetch( tools: Tool[], messages: Message[], ): Promise<Attachment[]> - 核心逻辑(参照
skillSearch/prefetch.ts:startSkillDiscoveryPrefetchL249-296 的模式):- 调用
extractQueryFromMessages(null, messages)提取用户查询文本(复用skillSearch/prefetch.ts导出的extractQueryFromMessages函数,该函数已导出且逻辑通用) queryText为空时返回[]- 记录
startedAt = Date.now() - 调用
getToolIndex(tools)获取缓存的工具索引 - 调用
searchTools(queryText, index, 3)搜索 top-3 工具(预取场景限制 3 条,减少 token 开销) - 过滤会话内已发现的工具(定义模块级
discoveredToolsThisSession: Set<string>,与 skill prefetch 的discoveredThisSession独立) - 结果为空时返回
[] - 记录
logForDebugging日志 - 返回
[buildToolDiscoveryAttachment(filteredResults, 'assistant_turn', queryText, durationMs, index.length)] - catch 块返回
[](fire-and-forget,不向上传播错误)
- 调用
- 原因: 异步预取不阻塞主流程,与 skill prefetch 保持一致的错误处理策略(静默失败)
- 位置:
-
实现
getTurnZeroToolSearchPrefetch同步获取函数 — turn-0 场景,用户首次输入时阻塞式获取- 位置:
src/services/toolSearch/prefetch.ts,在startToolSearchPrefetch之后 - 函数签名:
export async function getTurnZeroToolSearchPrefetch( input: string, tools: Tool[], ): Promise<Attachment | null> - 核心逻辑(参照
skillSearch/prefetch.ts:getTurnZeroSkillDiscoveryL308-356 的模式):input为空时返回null- 记录
startedAt = Date.now() - 调用
getToolIndex(tools)获取工具索引 - 调用
searchTools(input, index, 3)搜索 top-3 工具 - 结果为空时返回
null - 将结果工具名加入
discoveredToolsThisSession - 记录
logForDebugging日志 - 返回
buildToolDiscoveryAttachment(results, 'user_input', input, durationMs, index.length) - catch 块返回
null
- 原因: turn-0 是唯一的阻塞式入口,因为此时没有其他计算可以隐藏预取延迟。与 skill prefetch 保持一致的设计
- 位置:
-
实现
collectToolSearchPrefetch结果收集函数 — 等待异步预取完成并收集结果- 位置:
src/services/toolSearch/prefetch.ts,在getTurnZeroToolSearchPrefetch之后 - 函数签名:
export async function collectToolSearchPrefetch( pending: Promise<Attachment[]>, ): Promise<Attachment[]> - 核心逻辑(与
skillSearch/prefetch.ts:collectSkillDiscoveryPrefetchL298-306 完全一致):try { return await pending } catch { return [] } - 原因: 包装 Promise,确保预取失败时返回空数组而非抛出异常
- 位置:
-
在
src/utils/attachments.ts中注册tool_discoveryattachment type — 扩展 Attachment 联合类型- 位置:
src/utils/attachments.ts的Attachment类型定义中,在skill_discovery类型分支(L534-L555)之后 - 新增 import(文件顶部 import 区域):
import type { ToolDiscoveryResult } from '../services/toolSearch/prefetch.js' - 在
skill_discovery分支后追加tool_discovery类型:| { type: 'tool_discovery' tools: ToolDiscoveryResult[] trigger: 'assistant_turn' | 'user_input' queryText: string durationMs: number indexSize: number } - 原因:
createAttachmentMessage接收Attachment类型参数,必须将tool_discovery注册到联合类型中才能通过类型检查
- 位置:
-
在
src/utils/attachments.ts中集成 turn-0 工具预取 — 在 skill discovery 附件之后添加 tool discovery 附件- 位置:
src/utils/attachments.ts的getAttachmentMessages函数中,在 skill discovery 的maybe('skill_discovery', ...)调用块(L818-L831)之后 - 新增条件 require 模块(与
skillSearchModules模式一致,在文件顶部 ~L92skillSearchModules定义之后):const toolSearchModules = feature('EXPERIMENTAL_TOOL_SEARCH') ? { prefetch: require('../services/toolSearch/prefetch.js') as typeof import('../services/toolSearch/prefetch.js'), } : null - 在 skill discovery 的 spread 数组中追加 tool discovery 附件(在
]闭合maybe('skill_discovery', ...)之后,在外层 spread...(feature('EXPERIMENTAL_SKILL_SEARCH') &&的]之前):...(feature('EXPERIMENTAL_TOOL_SEARCH') && toolSearchModules && !options?.skipSkillDiscovery ? [ maybe('tool_discovery', async () => { if (suppressNextDiscovery) { return [] } const result = await toolSearchModules.prefetch.getTurnZeroToolSearchPrefetch( input, context.options.tools ?? [], ) return result ? [result] : [] }), ] : []), - 注意:
suppressNextDiscovery与 skill discovery 共用同一个标志(skill expansion 路径不应触发工具发现,语义一致) - 原因: turn-0 预取与 skill discovery 共享同一集成点(
getAttachmentMessages),两者互不干扰,各自生成独立 attachment
- 位置:
-
在
src/query.ts中集成 inter-turn 工具预取触发 — 在 skill prefetch 之后异步启动工具预取- 位置:
src/query.ts文件顶部 conditional require 区域(~L68-70skillPrefetch定义之后) - 新增 conditional require:
const toolSearchPrefetch = feature('EXPERIMENTAL_TOOL_SEARCH') ? (require('./services/toolSearch/prefetch.js') as typeof import('./services/toolSearch/prefetch.js')) : null - 位置:
src/query.ts的queryLoop函数中,在pendingSkillPrefetch定义(L480-484)之后 - 新增工具预取触发:
const pendingToolPrefetch = toolSearchPrefetch?.startToolSearchPrefetch( state.tools ?? [], messages, ) - 原因: 与 skill prefetch 保持相同的触发时机(每轮迭代开始时异步启动),两者并行执行互不阻塞
- 位置:
-
在
src/query.ts中集成工具预取结果消费 — 在 skill prefetch 收集之后收集工具预取结果- 位置:
src/query.ts的queryLoop函数中,在 skill prefetch 结果消费块(L1910-L1918)之后 - 新增工具预取结果消费:
if (toolSearchPrefetch && pendingToolPrefetch) { const toolAttachments = await toolSearchPrefetch.collectToolSearchPrefetch(pendingToolPrefetch) for (const att of toolAttachments) { const msg = createAttachmentMessage(att) yield msg toolResults.push(msg) } } - 原因: 与 skill prefetch 结果消费保持一致的位置和模式(post-tools 阶段注入),确保预取结果在本轮工具执行完成后、下一轮模型调用前注入
- 位置:
-
为
prefetch.ts核心逻辑编写单元测试- 测试文件:
src/services/toolSearch/__tests__/prefetch.test.ts(新建) - 测试框架:
bun:test - 测试场景:
startToolSearchPrefetch— 正常调用: 构造 mock Tool 数组和 mock messages,mockgetToolIndex返回固定索引,mocksearchTools返回匹配结果,验证返回的Attachment[]包含type: 'tool_discovery'且tools非空、trigger为'assistant_turn'startToolSearchPrefetch— 空查询: messages 中无用户文本内容,验证返回空数组startToolSearchPrefetch— 无匹配:searchTools返回空数组,验证返回空数组startToolSearchPrefetch— 异常安全: mockgetToolIndex抛出异常,验证返回空数组(不抛出)startToolSearchPrefetch— 会话去重: 连续两次调用传入相同工具名,第二次返回空数组(已被discoveredToolsThisSession过滤)getTurnZeroToolSearchPrefetch— 正常调用: 传入有效 input 和 mock tools,验证返回非 null 的Attachment,trigger为'user_input'getTurnZeroToolSearchPrefetch— 空输入: 传入空字符串,验证返回 nullgetTurnZeroToolSearchPrefetch— 无匹配:searchTools返回空数组,验证返回 nullcollectToolSearchPrefetch— 正常收集: 传入 resolved promise,验证返回对应 attachment 数组collectToolSearchPrefetch— 异常安全: 传入 rejected promise,验证返回空数组buildToolDiscoveryAttachment— 返回的 attachment 对象包含type: 'tool_discovery'、tools、trigger、queryText、durationMs、indexSize字段
- Mock 策略: 使用
bun:test的mock函数 mock./toolIndex.js的getToolIndex和searchTools;构造Partial<Tool>类型的 mock Tool 对象;构造包含{ type: 'user', content: 'test query' }的 mock Message 数组 - 运行命令:
bun test src/services/toolSearch/__tests__/prefetch.test.ts - 预期: 所有测试通过
- 测试文件:
检查步骤:
-
验证
prefetch.ts文件存在且导出正确grep -c "export async function\|export type" src/services/toolSearch/prefetch.ts- 预期: 至少 5(startToolSearchPrefetch, getTurnZeroToolSearchPrefetch, collectToolSearchPrefetch, ToolDiscoveryResult, extractQueryFromMessages import)
-
验证
tool_discovery类型已注册到 Attachment 联合类型grep -n "tool_discovery" src/utils/attachments.ts- 预期: 至少 2 行(类型定义 + maybe 调用)
-
验证
query.ts中工具预取触发和消费代码已添加grep -n "toolSearchPrefetch\|pendingToolPrefetch\|collectToolSearchPrefetch" src/query.ts- 预期: 至少 6 行(conditional require + start 调用 + if + collect 调用 + yield)
-
验证
attachments.ts中 turn-0 工具预取已集成grep -n "getTurnZeroToolSearchPrefetch\|toolSearchModules" src/utils/attachments.ts- 预期: 至少 3 行(conditional require + getTurnZero 调用 + toolSearchModules 使用)
-
验证 TypeScript 编译无错误
bunx tsc --noEmit --pretty 2>&1 | head -30- 预期: 无新增类型错误
-
验证单元测试通过
bun test src/services/toolSearch/__tests__/prefetch.test.ts 2>&1 | tail -10- 预期: 输出包含 "pass" 且无 "fail"
认知变更:
- [CLAUDE.md]
src/services/toolSearch/prefetch.ts的extractQueryFromMessages复用了src/services/skillSearch/prefetch.ts的同名导出函数。修改skillSearch/prefetch.ts的extractQueryFromMessages时需同步检查工具预取的行为。工具预取使用独立的discoveredToolsThisSessionSet,与 skill prefetch 的去重集合互不影响。
Task 7: 用户推荐 UI
背景:
[业务语境] — 在 REPL 输入区域上方渲染工具推荐提示条,帮助用户了解哪些工具适合当前任务,提升工具发现体验
[修改原因] — 当前缺少面向用户的工具推荐可视化,预取管道(Task 6)产出的匹配结果无法被用户感知
[上下游影响] — 本 Task 消费 Task 6 collectToolSearchPrefetch() 的预取结果数据;本 Task 的组件挂载到 REPL.tsx 的对话框优先级系统中
涉及文件:
- 新建:
src/components/ToolSearchHint.tsx - 新建:
src/components/__tests__/ToolSearchHint.test.ts - 修改:
src/screens/REPL.tsx
执行步骤:
-
新建
src/components/ToolSearchHint.tsx— Ink 组件,渲染工具推荐提示条- 位置: 新建文件,参照
src/components/ClaudeCodeHint/PluginHintMenu.tsx的结构模式 - 组件签名:
type ToolSearchHintItem = { name: string; description: string; score: number; }; type Props = { tools: ToolSearchHintItem[]; onSelect: (toolName: string) => void; onDismiss: () => void; }; export function ToolSearchHint({ tools, onSelect, onDismiss }: Props): React.ReactNode; - 使用
PermissionDialog(从src/components/permissions/PermissionDialog.js)作为外层容器,title 设为"Tool Recommendation" - 使用
Select(从src/components/CustomSelect/select.js)渲染可选工具列表,每个选项格式为:<工具名> — <描述截断至 60 字符> (score: 0.XX) - 额外增加一个 "Dismiss" 选项(value:
'dismiss'),排在选项列表末尾 onSelect回调: 当用户选中某个工具时调用onDismiss()清除推荐,并调用onSelect(toolName)将工具名传递给 REPL 层追加到用户消息上下文- 30 秒自动 dismiss(复用
PluginHintMenu的AUTO_DISMISS_MS = 30_000模式),通过setTimeout+useRef实现,超时调用onDismiss() useEffect清理函数中clearTimeout防止内存泄漏- 原因: 遵循现有 UI 提示集成模式(PluginHintMenu),保证交互风格一致
- 位置: 新建文件,参照
-
新建
src/hooks/useToolSearchHint.ts— 自定义 Hook,管理工具推荐状态与生命周期- 位置: 新建文件,参照
src/hooks/useClaudeCodeHintRecommendation.tsx的状态管理模式 - Hook 签名:
type ToolSearchHintResult = { tools: ToolSearchHintItem[]; visible: boolean; handleSelect: (toolName: string) => void; handleDismiss: () => void; }; export function useToolSearchHint(): ToolSearchHintResult; - 内部使用
React.useSyncExternalStore订阅预取结果(从 Task 6 的src/services/toolSearch/prefetch.ts中导出的模块级缓存),subscribe 函数和 getSnapshot 函数从 prefetch 模块获取 tools字段: 从预取结果中提取前 3 个工具,每个工具包含name、description(截断至 60 字符)、scorevisible字段: 当tools非空且最高 score >= 0.15 时为 truehandleSelect: 记录用户选择(analytics 事件tengu_tool_search_hint_select),然后清除推荐状态handleDismiss: 记录 dismiss 事件(analytics 事件tengu_tool_search_hint_dismiss),清除推荐状态- 清除推荐状态时调用 prefetch 模块的清除函数(
clearToolSearchPrefetchResults(),由 Task 6 提供) - 原因: 将状态管理与 UI 渲染解耦,遵循现有 hook 模式(useClaudeCodeHintRecommendation)
- 位置: 新建文件,参照
-
修改
src/screens/REPL.tsx— 集成 ToolSearchHint 组件到对话框优先级系统- 位置:
getFocusedInputDialog()函数(~L2377),在返回类型联合中新增'tool-search-hint' - 在
getFocusedInputDialog()函数体中,在plugin-hint判断(~L2446)之后、desktop-upsell判断(~L2449)之前,新增一个优先级分支:if (allowDialogsWithAnimation && toolSearchHint.visible) return 'tool-search-hint'; - 位置: 文件顶部 import 区域(~L448,
PluginHintMenuimport 附近),新增 import:import { ToolSearchHint } from '../components/ToolSearchHint.js'; import { useToolSearchHint } from '../hooks/useToolSearchHint.js'; - 位置: hook 调用区域(~L1038,
useClaudeCodeHintRecommendation调用之后),新增:const toolSearchHint = useToolSearchHint(); - 位置: JSX 渲染区域(~L6174,
PluginHintMenu渲染块之后),新增条件渲染块:{focusedInputDialog === 'tool-search-hint' && toolSearchHint.visible && ( <ToolSearchHint tools={toolSearchHint.tools} onSelect={toolSearchHint.handleSelect} onDismiss={toolSearchHint.handleDismiss} /> )} - 原因: 遵循 REPL 的 focusedInputDialog 优先级系统,确保工具推荐提示在合适的时机显示,不阻塞高优先级对话框
- 位置:
-
为
ToolSearchHint组件和useToolSearchHinthook 编写单元测试- 测试文件:
src/components/__tests__/ToolSearchHint.test.ts - 测试场景:
- 当
tools数组为空时,useToolSearchHint返回visible: false - 当
tools数组非空且最高 score >= 0.15 时,useToolSearchHint返回visible: true且tools包含最多 3 个条目 - 当最高 score < 0.15 时,
useToolSearchHint返回visible: false handleDismiss调用后推荐状态被清除handleSelect调用后推荐状态被清除且回调被触发
- 当
- 使用
bun:test框架(与项目现有测试一致) - 运行命令:
bun test src/components/__tests__/ToolSearchHint.test.ts - 预期: 所有测试通过
- 测试文件:
检查步骤:
- 验证新文件已创建且导出正确
grep -c "export function ToolSearchHint" src/components/ToolSearchHint.tsx && grep -c "export function useToolSearchHint" src/hooks/useToolSearchHint.ts- 预期: 两个 grep 均返回 1
- 验证 REPL.tsx 集成正确
grep -c "ToolSearchHint" src/screens/REPL.tsx && grep -c "tool-search-hint" src/screens/REPL.tsx- 预期: 两个 grep 均返回值 >= 2(import + hook + 渲染 + 优先级判断)
- 验证 TypeScript 编译无错误
npx tsc --noEmit --pretty 2>&1 | grep -E "ToolSearchHint|useToolSearchHint" | head -5- 预期: 无输出(无相关类型错误)
- 验证单元测试通过
bun test src/components/__tests__/ToolSearchHint.test.ts- 预期: 所有测试通过,无失败
Task 8: 全功能验收
前置条件:
- Plan 1(Task 1-4)和 Plan 2(Task 5-7)全部完成
bun run build可用
端到端验证:
-
运行完整测试套件确保无回归
bun test 2>&1 | tail -20- 预期: 全部测试通过(包含 Plan 1 和 Plan 2 新增的所有测试文件)
- 失败排查: 检查对应 Task 的测试步骤,确认 mock 配置和 import 路径
-
运行 precheck 确保 typecheck + lint + test 全部通过
bun run precheck 2>&1 | tail -20- 预期: 零错误通过
- 失败排查: 类型错误检查 import 路径;lint 错误检查格式;测试失败检查对应 Task
-
验证系统提示词引导文本正确注入
bun run dev -- --dump-system-prompt 2>&1 | grep -A5 "ToolSearch"- 预期: 输出包含 "use ToolSearch to discover" 引导文本
- 失败排查: 检查 Task 5 的 prompts.ts 修改
-
验证 ExecuteTool 在工具列表中可见
bun run dev -- --dump-system-prompt 2>&1 | grep "ExecuteTool"- 预期: 输出包含 ExecuteTool 工具定义
- 失败排查: 检查 Task 5 的 tools.ts 注册
-
验证构建产物正确
bun run build 2>&1 | tail -5- 预期: 构建成功,输出 dist/cli.js
- 失败排查: 检查新增文件的 import 是否兼容 Bun.build splitting
-
验证延迟工具数量正确
grep -c "isDeferredTool" src/utils/toolSearch.ts src/services/api/claude.ts packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts 2>/dev/null- 预期: 所有调用点仍在使用 isDeferredTool(已被 Task 1 重构为白名单制)
- 失败排查: 检查 Task 1 的 isDeferredTool 重构