mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
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:
53
src/components/ToolSearchHint.tsx
Normal file
53
src/components/ToolSearchHint.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/components/__tests__/ToolSearchHint.test.ts
Normal file
80
src/components/__tests__/ToolSearchHint.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user