Merge pull request #442 from claude-code-best/feature/tool_search

feat: 支持 SearchExtraTools 能力以替代 Tool Search
This commit is contained in:
claude-code-best
2026-05-09 17:23:03 +08:00
committed by GitHub
78 changed files with 4987 additions and 791 deletions

View File

@@ -6,7 +6,7 @@ import type { Tools } from '../Tool.js';
import type { RenderableMessage } from '../types/message.js';
import {
getDisplayMessageFromCollapsed,
getToolSearchOrReadInfo,
getSearchExtraToolsOrReadInfo,
getToolUseIdsFromCollapsedGroup,
hasAnyToolInProgress,
} from '../utils/collapseReadSearch.js';
@@ -89,7 +89,7 @@ export function hasContentAfterIndex(
continue;
}
if (content?.type === 'tool_use') {
if (getToolSearchOrReadInfo(content.name!, content.input, tools).isCollapsible) {
if (getSearchExtraToolsOrReadInfo(content.name!, content.input, tools).isCollapsible) {
continue;
}
// Non-collapsible tool uses appear in syntheticStreamingToolUseMessages
@@ -115,7 +115,7 @@ export function hasContentAfterIndex(
// merged into the current collapsed group on the next render cycle
if (msg?.type === 'grouped_tool_use') {
const firstInput = firstBlock(msg.messages[0]?.message.content)?.input;
if (getToolSearchOrReadInfo(msg.toolName, firstInput, tools).isCollapsible) {
if (getSearchExtraToolsOrReadInfo(msg.toolName, firstInput, tools).isCollapsible) {
continue;
}
}

View File

@@ -852,7 +852,7 @@ const MessagesImpl = ({
// renderToolResultMessage shows. Falls back to renderableSearchText
// (duck-types toolUseResult) for tools that haven't implemented it,
// and for all non-tool-result message types. The drift-catcher test
// (toolSearchText.test.tsx) renders + compares to keep these in sync.
// (searchExtraToolsText.test.tsx) renders + compares to keep these in sync.
//
// A second-React-root reconcile approach was tried and ruled out
// (measured 3.1ms/msg, growing — flushSyncWork processes all roots;

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 SearchExtraToolsHintItem = {
name: string;
description: string;
score: number;
};
type Props = {
tools: SearchExtraToolsHintItem[];
onSelect: (toolName: string) => void;
onDismiss: () => void;
};
const AUTO_DISMISS_MS = 30_000;
export function SearchExtraToolsHint({ 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,82 @@
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 {
subscribeToSearchExtraToolsPrefetch,
getSearchExtraToolsPrefetchSnapshot,
clearSearchExtraToolsPrefetchResults,
} = await import('src/services/searchExtraTools/prefetch.js')
const { useSearchExtraToolsHint } = await import(
'src/hooks/useSearchExtraToolsHint.js'
)
describe('useSearchExtraToolsHint', () => {
// We test the subscription/snapshot API directly since
// React hooks require a renderer.
test('returns empty tools when no prefetch result', () => {
clearSearchExtraToolsPrefetchResults()
const snapshot = getSearchExtraToolsPrefetchSnapshot()
expect(snapshot).toEqual([])
})
test('snapshot updates when listeners are notified', () => {
clearSearchExtraToolsPrefetchResults()
// 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
clearSearchExtraToolsPrefetchResults()
}
// Test subscription
let callCount = 0
const unsubscribe = subscribeToSearchExtraToolsPrefetch(() => {
callCount++
})
expect(callCount).toBe(0)
// Trigger a notification via clear
mockSetResults([])
expect(callCount).toBe(1)
// Unsubscribe and verify no more calls
unsubscribe()
clearSearchExtraToolsPrefetchResults()
expect(callCount).toBe(1)
})
test('clearSearchExtraToolsPrefetchResults resets snapshot', () => {
clearSearchExtraToolsPrefetchResults()
expect(getSearchExtraToolsPrefetchSnapshot()).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_SEARCH_EXTRA_TOOLS')) {
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;
}
}

View File

@@ -57,7 +57,7 @@ function VerboseToolUse({
theme: ThemeName;
}): React.ReactNode {
const bg = useSelectedMessageBg();
// Same REPL-primitive fallback as getToolSearchOrReadInfo — REPL mode strips
// Same REPL-primitive fallback as getSearchExtraToolsOrReadInfo — REPL mode strips
// these from the execution tools list, but virtual messages still need them
// to render in verbose mode.
const tool = findToolByName(tools, content.name) ?? findToolByName(getReplPrimitiveTools(), content.name);