feat: 实现 Tool Search 基础设施层(CORE_TOOLS 白名单 + TF-IDF 索引 + ExecuteTool + 搜索增强)

- 新增 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>
This commit is contained in:
claude-code-best
2026-05-08 22:29:15 +08:00
parent 02dd796706
commit 7be08f53bd
34 changed files with 4040 additions and 90 deletions

View File

@@ -0,0 +1,53 @@
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
import { Select } from './CustomSelect/select.js';
import { PermissionDialog } from './permissions/PermissionDialog.js';
type ToolSearchHintItem = {
name: string;
description: string;
score: number;
};
type Props = {
tools: ToolSearchHintItem[];
onSelect: (toolName: string) => void;
onDismiss: () => void;
};
const AUTO_DISMISS_MS = 30_000;
export function ToolSearchHint({ tools, onSelect, onDismiss }: Props): React.ReactNode {
const onSelectRef = React.useRef(onSelect);
const onDismissRef = React.useRef(onDismiss);
onSelectRef.current = onSelect;
onDismissRef.current = onDismiss;
React.useEffect(() => {
const timeoutId = setTimeout(ref => ref.current(), AUTO_DISMISS_MS, onDismissRef);
return () => clearTimeout(timeoutId);
}, []);
const options = tools.map(t => ({
label: `${t.name}${t.description.slice(0, 60)} (score: ${t.score.toFixed(2)})`,
value: t.name,
}));
options.push({ label: 'Dismiss', value: '__dismiss__' });
return (
<PermissionDialog title="Tool Recommendation">
<Select
options={options}
onChange={value => {
if (value === '__dismiss__') {
onDismissRef.current();
} else {
onDismissRef.current();
onSelectRef.current(value);
}
}}
/>
</PermissionDialog>
);
}

View File

@@ -0,0 +1,80 @@
import { describe, test, expect, beforeEach } from 'bun:test'
import { mock } from 'bun:test'
import { logMock } from '../../../tests/mocks/log'
import { debugMock } from '../../../tests/mocks/debug'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
mock.module('src/services/analytics/growthbook.js', () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
getFeatureValue_DEPRECATED: async () => undefined,
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
hasGrowthBookEnvOverride: () => false,
getAllGrowthBookFeatures: () => ({}),
getGrowthBookConfigOverrides: () => ({}),
setGrowthBookConfigOverride: () => {},
clearGrowthBookConfigOverrides: () => {},
getApiBaseUrlHost: () => undefined,
onGrowthBookRefresh: () => {},
initializeGrowthBook: async () => {},
checkSecurityRestrictionGate: async () => false,
checkGate_CACHED_OR_BLOCKING: async () => false,
refreshGrowthBookAfterAuthChange: () => {},
resetGrowthBook: () => {},
refreshGrowthBookFeatures: async () => {},
setupPeriodicGrowthBookRefresh: () => {},
stopPeriodicGrowthBookRefresh: () => {},
getDynamicConfig_CACHED_MAY_BE_STALE: () => undefined,
getDynamicConfig_BLOCKS_ON_INIT: async () => undefined,
}))
const {
subscribeToToolSearchPrefetch,
getToolSearchPrefetchSnapshot,
clearToolSearchPrefetchResults,
} = await import('src/services/toolSearch/prefetch.js')
const { useToolSearchHint } = await import('src/hooks/useToolSearchHint.js')
describe('useToolSearchHint', () => {
// We test the subscription/snapshot API directly since
// React hooks require a renderer.
test('returns empty tools when no prefetch result', () => {
clearToolSearchPrefetchResults()
const snapshot = getToolSearchPrefetchSnapshot()
expect(snapshot).toEqual([])
})
test('snapshot updates when listeners are notified', () => {
clearToolSearchPrefetchResults()
// Simulate what prefetch does: set results and notify
const mockSetResults = (results: unknown[]) => {
// We can't directly set latestPrefetchResult, but we can test
// the clear function and subscription mechanism
clearToolSearchPrefetchResults()
}
// Test subscription
let callCount = 0
const unsubscribe = subscribeToToolSearchPrefetch(() => {
callCount++
})
expect(callCount).toBe(0)
// Trigger a notification via clear
mockSetResults([])
expect(callCount).toBe(1)
// Unsubscribe and verify no more calls
unsubscribe()
clearToolSearchPrefetchResults()
expect(callCount).toBe(1)
})
test('clearToolSearchPrefetchResults resets snapshot', () => {
clearToolSearchPrefetchResults()
expect(getToolSearchPrefetchSnapshot()).toEqual([])
})
})

View File

@@ -138,7 +138,22 @@ export function AttachmentMessage({ attachment, addMargin, verbose, isTranscript
}
}
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- teammate_mailbox/skill_discovery handled before switch
// tool_discovery rendered here (not in the switch) so the 'tool_discovery'
// string literal stays inside a feature()-guarded block.
if (feature('EXPERIMENTAL_TOOL_SEARCH')) {
if (attachment.type === 'tool_discovery') {
if (attachment.tools.length === 0) return null;
const names = attachment.tools.map(t => t.name).join(', ');
return (
<Line>
<Text dimColor>Discovered tools: </Text>
<Text>{names}</Text>
</Line>
);
}
}
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- teammate_mailbox/skill_discovery/tool_discovery handled before switch
switch (attachment.type) {
case 'directory':
return (
@@ -396,7 +411,12 @@ export function AttachmentMessage({ attachment, addMargin, verbose, isTranscript
// skill_discovery and teammate_mailbox are handled BEFORE the switch in
// runtime-gated blocks (feature() / isAgentSwarmsEnabled()) that TS can't
// narrow through — excluded here via type union (compile-time only, no emit).
attachment.type satisfies NullRenderingAttachmentType | 'skill_discovery' | 'teammate_mailbox' | 'bagel_console';
attachment.type satisfies
| NullRenderingAttachmentType
| 'skill_discovery'
| 'tool_discovery'
| 'teammate_mailbox'
| 'bagel_console';
return null;
}
}