Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ bun run docs:dev

- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`src/constants/tools.ts`** — `CORE_TOOLS` 白名单常量(约 29 个核心工具名),用于 `isDeferredTool` 白名单制判定。
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
Expand All @@ -132,6 +133,7 @@ bun run docs:dev
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
- **`src/services/searchExtraTools/`** — TF-IDF 工具索引模块(`toolIndex.ts`),为延迟工具提供语义搜索能力。复用 `localSearch.ts` 的 TF-IDF 算法函数(`computeWeightedTf`、`computeIdf`、`cosineSimilarity` 已导出)。修改这些函数时需同步检查工具索引测试。`SearchExtraToolsTool.mapToolResultToToolResultBlockParam` 新增可选第三个参数 `context?: { mainLoopModel?: string }`,用于判断当前模型是否支持 `tool_reference`。不支持时回退到文本输出,引导模型使用 ExecuteTool。调用方(`src/services/api/claude.ts` 的 tool_result 处理逻辑)需传入 context 参数。`prefetch.ts` 的 `extractQueryFromMessages` 复用了 `skillSearch/prefetch.ts` 的同名导出函数,修改 skill prefetch 的该函数时需同步检查工具预取行为。工具预取使用独立的 `discoveredToolsThisSession` Set,与 skill prefetch 的去重集合互不影响。

### UI Layer (Ink)

Expand Down
323 changes: 323 additions & 0 deletions docs/design/tool-search-design-guide.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/@ant/computer-use-mcp/src/toolCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ async function runInputActionGates(
`visible in screenshots only, no clicks or typing.` +
(isBrowser
? ' Use the Claude-in-Chrome MCP for browser interaction (tools ' +
'named `mcp__Claude_in_Chrome__*`; load via ToolSearch if ' +
'named `mcp__Claude_in_Chrome__*`; load via SearchExtraTools if ' +
'deferred).'
: ' No interaction is permitted; ask the user to take any ' +
'actions in this app themselves.') +
Expand Down Expand Up @@ -1308,7 +1308,7 @@ function buildTierGuidanceMessage(tiered: TieredApp[]): string {
`typing). You can read what's on screen but cannot navigate, click, ` +
`or type into ${readBrowsers.length === 1 ? 'it' : 'them'}. For browser ` +
`interaction, use the Claude-in-Chrome MCP (tools named ` +
`\`mcp__Claude_in_Chrome__*\`; load via ToolSearch if deferred).`,
`\`mcp__Claude_in_Chrome__*\`; load via SearchExtraTools if deferred).`,
)
}

Expand Down
2 changes: 1 addition & 1 deletion packages/builtin-tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export { SkillTool } from './tools/SkillTool/SkillTool.js'
export { TaskOutputTool } from './tools/TaskOutputTool/TaskOutputTool.js'
export { TaskStopTool } from './tools/TaskStopTool/TaskStopTool.js'
export { TodoWriteTool } from './tools/TodoWriteTool/TodoWriteTool.js'
export { ToolSearchTool } from './tools/ToolSearchTool/ToolSearchTool.js'
export { SearchExtraToolsTool } from './tools/SearchExtraToolsTool/SearchExtraToolsTool.js'
export { TungstenTool } from './tools/TungstenTool/TungstenTool.js'
export { WebFetchTool } from './tools/WebFetchTool/WebFetchTool.js'
export { WebSearchTool } from './tools/WebSearchTool/WebSearchTool.js'
Expand Down
3 changes: 2 additions & 1 deletion packages/builtin-tools/src/tools/BriefTool/BriefTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { buildTool, type ToolDef } from 'src/Tool.js'
import { isEnvTruthy } from 'src/utils/envUtils.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { plural } from 'src/utils/stringUtils.js'
import { isBridgeEnabled } from 'src/bridge/bridgeEnabled.js'
import { resolveAttachments, validateAttachmentPaths } from './attachments.js'
import {
BRIEF_TOOL_NAME,
Expand Down Expand Up @@ -149,7 +150,7 @@ export const BriefTool = buildTool({
return outputSchema()
},
isEnabled() {
return isBriefEnabled()
return isBridgeEnabled()
},
Comment on lines 152 to 154
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Critical: Brief enablement logic bypasses opt-in and entitlement checks.

Switching from isBriefEnabled() to isBridgeEnabled() removes all user controls that gate Brief activation:

  1. Opt-in requirement removed: Users who haven't explicitly opted in via --brief, defaultView: 'chat', /brief, or CLAUDE_CODE_BRIEF env var will now see the tool whenever the bridge is enabled.

  2. GrowthBook gate bypassed: The tengu_kairos_brief feature flag (refreshed every 5 minutes per line 98) is no longer checked, potentially exposing the tool outside the intended experiment cohort.

  3. Documentation mismatch: Lines 103-126 describe isBriefEnabled() as the "unified activation gate" that "governs model-facing behavior as a unit: tool availability, system prompt section, tool-deferral bypass, and todo-nag suppression." That function is still exported but no longer used by the tool.

If the intent is to gate on bridge availability in addition to the existing checks, the fix should be:

  isEnabled() {
-    return isBridgeEnabled()
+    return isBriefEnabled() && isBridgeEnabled()
  },

If the intent is to replace all Brief-specific gates with a simple bridge check, then isBriefEnabled() should be removed and its call sites updated to prevent confusion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/builtin-tools/src/tools/BriefTool/BriefTool.ts` around lines 152 -
154, The current isEnabled() replaced isBriefEnabled() with isBridgeEnabled(),
which bypasses opt-in and GrowthBook gates; restore the unified Brief gate by
making isEnabled() require both bridge availability and Brief-specific checks
(i.e., use isBridgeEnabled() && isBriefEnabled()) so the existing opt-in flags
and the tengu_kairos_brief feature flag remain enforced; if instead the
intention is to remove Brief-specific gating, delete the isBriefEnabled()
implementation and update all call sites to avoid confusion.

isConcurrencySafe() {
return true
Expand Down
147 changes: 147 additions & 0 deletions packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { z } from 'zod/v4'
import {
buildTool,
findToolByName,
type Tool,
type ToolDef,
type ToolUseContext,
type ToolResult,
type Tools,
} from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { createUserMessage } from 'src/utils/messages.js'
import { DESCRIPTION, getPrompt } from './prompt.js'
import { EXECUTE_TOOL_NAME } from './constants.js'

export const inputSchema = lazySchema(() =>
z.object({
tool_name: z
.string()
.describe(
'The exact name of the target tool to execute (e.g., "CronCreate", "mcp__server__action")',
),
params: z
.record(z.string(), z.unknown())
.describe('The parameters to pass to the target tool'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>

export const outputSchema = lazySchema(() =>
z.object({
result: z.unknown(),
tool_name: z.string(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>

export type Output = z.infer<OutputSchema>

export const ExecuteTool = buildTool({
name: EXECUTE_TOOL_NAME,
searchHint: 'execute run invoke call a deferred tool by name with parameters',
maxResultSizeChars: 100_000,
isConcurrencySafe() {
return false
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
async description() {
return DESCRIPTION
},
async prompt() {
return getPrompt()
},
async call(input, context, canUseTool, parentMessage, onProgress) {
const tools: Tools = context.options.tools ?? []

const targetTool = findToolByName(tools, input.tool_name)
if (!targetTool) {
return {
data: {
result: null,
tool_name: input.tool_name,
},
newMessages: [
createUserMessage({
content: `Tool "${input.tool_name}" not found. Use SearchExtraTools to discover available tools.`,
}),
],
}
}

// Check if the target tool is currently enabled
if (!targetTool.isEnabled()) {
return {
data: {
result: null,
tool_name: input.tool_name,
},
newMessages: [
createUserMessage({
content: `工具 "${input.tool_name}" 当前不可用:Remote Control 未连接。`,
}),
],
}
}

// Check permissions on the target tool
const permResult = await targetTool.checkPermissions?.(
input.params as Record<string, unknown>,
context,
)
if (permResult && permResult.behavior === 'deny') {
return {
data: {
result: null,
tool_name: input.tool_name,
},
newMessages: [
createUserMessage({
content: `Permission denied for tool "${input.tool_name}": ${permResult.message ?? 'Permission denied'}`,
}),
],
}
}
Comment on lines +93 to +109
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Permission gating is incomplete for delegated execution.

This path blocks only explicit deny, then executes the target tool. Any non-allow interactive behavior (e.g., approval-needed) can be bypassed here, which weakens permission enforcement for delegated tools.

Suggested guard pattern
-    if (permResult && permResult.behavior === 'deny') {
+    if (
+      permResult &&
+      permResult.behavior !== 'allow' &&
+      permResult.behavior !== 'passthrough'
+    ) {
       return {
         data: {
           result: null,
           tool_name: input.tool_name,
         },
         newMessages: [
           createUserMessage({
-            content: `Permission denied for tool "${input.tool_name}": ${permResult.message ?? 'Permission denied'}`,
+            content: `Tool "${input.tool_name}" requires permission before execution: ${permResult.message ?? 'Permission required'}`,
           }),
         ],
       }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts` around lines 93
- 109, The permission check currently only blocks when permResult.behavior ===
'deny', allowing non-allow behaviors (e.g., approval-needed or undefined) to
proceed; update the gating around targetTool.checkPermissions (the call
producing permResult) so that execution only proceeds when permResult exists and
permResult.behavior === 'allow'. For other cases return early like the deny
path: if behavior === 'deny' keep the existing denial response, and for any
non-allow behavior (including undefined) return a controlled response that
prevents execution (e.g., a permission-denied/approval-required user message
referencing input.tool_name and permResult.message) instead of falling through
to execute the tool. Ensure you modify the condition around the current if
(permResult && permResult.behavior === 'deny') and related control flow
accordingly.


// Delegate execution to the target tool
const targetResult: ToolResult<unknown> = await targetTool.call(
input.params as Record<string, unknown>,
context,
canUseTool,
parentMessage,
onProgress,
)

return {
...targetResult,
data: {
result: targetResult.data,
tool_name: input.tool_name,
},
}
},
async checkPermissions() {
return {
behavior: 'passthrough',
message: 'ExecuteExtraTool delegates permission to the target tool.',
}
},
renderToolUseMessage(input) {
return `Executing ${input.tool_name}...`
},
userFacingName() {
return 'ExecuteExtraTool'
},
mapToolResultToToolResultBlockParam(content, toolUseID) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: JSON.stringify(content),
}
Comment on lines +140 to +145
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Inspect ToolDef signature and tool_result content expectations:"
fd -i "Tool.ts" -x sh -c 'echo "---- $1 ----"; sed -n "1,260p" "$1"' sh {}

echo
echo "Compare mapToolResultToToolResultBlockParam implementations across tools:"
rg -n "mapToolResultToToolResultBlockParam\\(" --type=ts --type=tsx -C3

echo
echo "Inspect tool_result payload construction callsites:"
rg -n "tool_use_id|type:\\s*'tool_result'|content:" --type=ts --type=tsx -C2

Repository: claude-code-best/claude-code

Length of output: 50431


🏁 Script executed:

# Find ToolResultBlockParam type definition
rg -n "type ToolResultBlockParam|interface ToolResultBlockParam" --type=ts --type=tsx -A 10

# Check ExecuteTool implementation context
rg -n "class ExecuteTool|export.*ExecuteTool" --type=ts --type=tsx -B 2 -A 15

# Find where ExecuteTool.mapToolResultToToolResultBlockParam is called
rg -n "mapToolResultToToolResultBlockParam" packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts -B 10 -A 10

Repository: claude-code-best/claude-code

Length of output: 697


🏁 Script executed:

# Find ToolResultBlockParam type definition in src/Tool
fd -name "Tool.ts" -o -name "Tool.js" | head -5 | xargs -I {} sh -c 'echo "=== {} ===" && rg "ToolResultBlockParam" {} -B 2 -A 8'

# Also search for content property type in tool_result contexts
rg "type.*ToolResultBlockParam|interface.*ToolResultBlockParam" -A 10

Repository: claude-code-best/claude-code

Length of output: 44189


🏁 Script executed:

# Find ToolResultBlockParam type definition from SDK
rg "ToolResultBlockParam" src/Tool.ts -A 5

# Check the ExecuteTool file to understand the full context
fd -name "ExecuteTool.ts" | head -1 | xargs cat | head -200

# Search for examples of structured content blocks
rg "content.*\[" --type=ts --type=tsx | grep -i "tool_result\|type.*text" | head -20

Repository: claude-code-best/claude-code

Length of output: 1595


🏁 Script executed:

# Read ExecuteTool.ts file
fd -name "ExecuteTool.ts" -path "*/ExecuteTool/*" | xargs cat | head -250

# Also check what ToolResultBlockParam content type looks like from SDK
# by searching for examples in the codebase
rg "content:.*\[.*type.*text" --type=ts --type=tsx -A 2 | head -30

Repository: claude-code-best/claude-code

Length of output: 331


🏁 Script executed:

# Find the ExecuteTool file using git
git ls-files | grep -i executetool

# Read the file
cat packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts | head -250

# Search for examples where content is structured (array with type/text)
rg 'content:.*\[' packages/builtin-tools/src/tools -A 3 | head -40

Repository: claude-code-best/claude-code

Length of output: 7617


🏁 Script executed:

# Check what ToolResultBlockParam type actually allows for content
python3 << 'EOF'
import json

# Based on the code patterns, let me search for how content is actually used
# in the codebase to understand what type it should be
import subprocess

result = subprocess.run(
    ['rg', 'type.*content.*string|.*content.*array|ContentBlock', '--type=ts', '--type=tsx', '-A', '2'],
    capture_output=True,
    text=True
)

# Look for actual usage patterns
lines = result.stdout.split('\n')
for i, line in enumerate(lines[:100]):
    if 'content' in line.lower():
        print(line)
EOF

# Check SDK types by looking at how content is used in actual implementations
rg 'content:.*string|content:.*\[' packages/builtin-tools/src/tools -B 3 -A 1 | head -80

Repository: claude-code-best/claude-code

Length of output: 6276


🏁 Script executed:

# Look at TeamDeleteTool and TeamCreateTool to see the proper pattern
rg "mapToolResultToToolResultBlockParam" packages/builtin-tools/src/tools/TeamDeleteTool packages/builtin-tools/src/tools/TeamCreateTool -A 10 | head -50

# Check how delegated tool outputs flow through the system
rg "targetResult.*data|tool_result.*content" packages/builtin-tools/src/tools/ExecuteTool -B 3 -A 3

# Verify if ExecuteTool's output type matches what it passes to mapToolResultToToolResultBlockParam
rg "Output.*result|type.*Output" packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts -B 2 -A 2

Repository: claude-code-best/claude-code

Length of output: 2745


Preserve structured tool_result content blocks instead of JSON-stringifying.

ExecuteTool wraps delegated tool outputs, but mapToolResultToToolResultBlockParam incorrectly collapses the result into a JSON string (content: JSON.stringify(content)). This prevents downstream processing from interpreting structured content blocks. Follow the pattern used in TeamDeleteTool and TeamCreateTool: return content: [{ type: 'text', text: jsonStringify(data) }] instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts` around lines 140
- 145, The function mapToolResultToToolResultBlockParam currently
JSON.stringify's the entire content which collapses structured blocks; update it
to follow TeamDeleteTool/TeamCreateTool: return an object with tool_use_id and
type 'tool_result' but set content to an array like [{ type: 'text', text:
jsonStringify(content) }] (i.e., preserve structured content by wrapping a text
block containing the stringified data rather than assigning content:
JSON.stringify(content)). Ensure you reference
mapToolResultToToolResultBlockParam and use the same wrapping pattern used in
TeamDeleteTool/TeamCreateTool.

},
} satisfies ToolDef<InputSchema, Output>)
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { describe, test, expect } 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 all heavy dependencies before importing ExecuteTool
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: () => {},
}))

mock.module('src/utils/searchExtraTools.js', () => ({
isSearchExtraToolsEnabledOptimistic: () => true,
getAutoSearchExtraToolsCharThreshold: () => 100,
getSearchExtraToolsMode: () => 'tst' as const,
isSearchExtraToolsToolAvailable: async () => true,
isSearchExtraToolsEnabled: async () => true,
isToolReferenceBlock: () => false,
extractDiscoveredToolNames: () => new Set(),
isDeferredToolsDeltaEnabled: () => false,
getDeferredToolsDelta: () => null,
}))

mock.module('src/constants/tools.js', () => ({
CORE_TOOLS: new Set(['ExecuteExtraTool', 'SearchExtraTools']),
}))

// Mock messages module
mock.module('src/utils/messages.js', () => ({
createUserMessage: ({ content }: { content: string }) => ({
type: 'user' as const,
content,
uuid: 'test-uuid',
}),
}))

const { ExecuteTool } = await import('../ExecuteTool.js')
const { EXECUTE_TOOL_NAME } = await import('../constants.js')

function makeContext(tools: unknown[] = []) {
return {
options: {
tools,
},
cwd: '/tmp',
sessionId: 'test',
} as never
}

function makeMockTool(name: string, callResult: unknown = 'ok') {
return {
name,
call: async () => ({ data: callResult }),
checkPermissions: async () => ({ behavior: 'allow' as const }),
prompt: async () => `Description for ${name}`,
description: async () => `Description for ${name}`,
inputSchema: {},
isEnabled: () => true,
isConcurrencySafe: () => true,
isReadOnly: () => false,
isMcp: false,
alwaysLoad: undefined,
shouldDefer: undefined,
searchHint: '',
userFacingName: () => name,
renderToolUseMessage: () => `Running ${name}`,
mapToolResultToToolResultBlockParam: (content: unknown, id: string) => ({
tool_use_id: id,
type: 'tool_result',
content,
}),
}
}

describe('ExecuteTool', () => {
test('executes a target tool by name', async () => {
const mockTarget = makeMockTool('TestTool', { result: 'success' })
const ctx = makeContext([mockTarget])

const result = await ExecuteTool.call(
{ tool_name: 'TestTool', params: {} },
ctx,
async () => ({ behavior: 'allow' }),
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
undefined,
)

expect(result.data).toEqual({
result: { result: 'success' },
tool_name: 'TestTool',
})
})

test('returns error when tool not found', async () => {
const ctx = makeContext([])

const result = await ExecuteTool.call(
{ tool_name: 'NonexistentTool', params: {} },
ctx,
async () => ({ behavior: 'allow' }),
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
undefined,
)

expect(result.data).toEqual({
result: null,
tool_name: 'NonexistentTool',
})
expect(result.newMessages).toBeDefined()
expect(result.newMessages!.length).toBeGreaterThan(0)
})

test('returns permission denied when target denies', async () => {
const mockTarget = makeMockTool('SecretTool', 'secret')
mockTarget.checkPermissions = async () =>
({
behavior: 'deny' as const,
message: 'Access denied',
}) as never
const ctx = makeContext([mockTarget])

const result = await ExecuteTool.call(
{ tool_name: 'SecretTool', params: {} },
ctx,
async () => ({ behavior: 'allow' }),
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
undefined,
)

expect(result.data).toEqual({
result: null,
tool_name: 'SecretTool',
})
expect(result.newMessages).toBeDefined()
})

test('has correct name', () => {
expect(ExecuteTool.name).toBe(EXECUTE_TOOL_NAME)
})

test('searchHint contains keywords', () => {
expect(ExecuteTool.searchHint).toContain('execute')
expect(ExecuteTool.searchHint).toContain('tool')
})
})
Loading
Loading