mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: gate semantic search until initial indexing completes
- Add indexing status check to codebaseSearchTool before allowing search - Show clear error messages when indexing is in progress or not ready - Add pause/skip dialog when task starts during active indexing - Show progress updates if user chooses to wait for indexing - Fall back to file-based search tools when semantic search unavailable - Add comprehensive tests for indexing gate functionality Fixes #8234
This commit is contained in:
parent
0e1b23d09c
commit
cf4f3e1e46
3 changed files with 516 additions and 1 deletions
|
|
@ -63,6 +63,7 @@ import { BrowserSession } from "../../services/browser/BrowserSession"
|
|||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { McpServerManager } from "../../services/mcp/McpServerManager"
|
||||
import { RepoPerTaskCheckpointService } from "../../services/checkpoints"
|
||||
import { CodeIndexManager } from "../../services/code-index/manager"
|
||||
|
||||
// integrations
|
||||
import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
|
||||
|
|
@ -1684,6 +1685,78 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// Kicks off the checkpoints initialization process in the background.
|
||||
getCheckpointService(this)
|
||||
|
||||
// Check if code indexing is in progress and gate semantic search if needed
|
||||
const context = this.providerRef.deref()?.context
|
||||
if (context) {
|
||||
const codeIndexManager = CodeIndexManager.getInstance(context)
|
||||
if (codeIndexManager && codeIndexManager.isFeatureEnabled && codeIndexManager.isFeatureConfigured) {
|
||||
const status = codeIndexManager.getCurrentStatus()
|
||||
|
||||
// If indexing is in progress, offer to pause or skip
|
||||
if (status.systemStatus === "Indexing") {
|
||||
const progressPercentage =
|
||||
status.totalItems > 0 ? Math.round((status.processedItems / status.totalItems) * 100) : 0
|
||||
|
||||
const { response } = await this.ask(
|
||||
"followup",
|
||||
`🔄 Code indexing is currently ${progressPercentage}% complete (${status.processedItems}/${status.totalItems} ${status.currentItemUnit || "items"}). ` +
|
||||
`\n\nSemantic search will provide better results once indexing completes. ` +
|
||||
`\n\nWould you like to wait for indexing to finish, or continue without semantic search?` +
|
||||
`\n\n• **Wait**: Pause until indexing completes for best search results` +
|
||||
`\n• **Skip**: Continue now using file-based search tools`,
|
||||
)
|
||||
|
||||
if (response === "messageResponse" && this.askResponseText?.toLowerCase().includes("wait")) {
|
||||
// User chose to wait - show progress updates
|
||||
await this.say("text", "⏳ Waiting for code indexing to complete...")
|
||||
|
||||
// Poll for indexing completion
|
||||
while (!this.abort) {
|
||||
const currentStatus = codeIndexManager.getCurrentStatus()
|
||||
if (currentStatus.systemStatus === "Indexed") {
|
||||
await this.say("text", "✅ Code indexing complete! Semantic search is now available.")
|
||||
break
|
||||
} else if (currentStatus.systemStatus === "Error") {
|
||||
await this.say(
|
||||
"error",
|
||||
"Code indexing encountered an error. Proceeding without semantic search.",
|
||||
)
|
||||
break
|
||||
} else if (currentStatus.systemStatus === "Indexing") {
|
||||
const currentProgress =
|
||||
currentStatus.totalItems > 0
|
||||
? Math.round((currentStatus.processedItems / currentStatus.totalItems) * 100)
|
||||
: 0
|
||||
await this.say(
|
||||
"text",
|
||||
`📊 Indexing progress: ${currentProgress}% (${currentStatus.processedItems}/${currentStatus.totalItems} ${currentStatus.currentItemUnit || "items"})`,
|
||||
undefined,
|
||||
true, // partial update
|
||||
)
|
||||
await delay(2000) // Check every 2 seconds
|
||||
} else {
|
||||
// Standby or other state - stop waiting
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User chose to skip - continue without semantic search
|
||||
await this.say(
|
||||
"text",
|
||||
"⏭️ Continuing without semantic search. File-based search tools will be used instead.",
|
||||
)
|
||||
}
|
||||
} else if (status.systemStatus === "Standby" && !codeIndexManager.isInitialized) {
|
||||
// Index hasn't started yet
|
||||
await this.say(
|
||||
"text",
|
||||
"ℹ️ Code index has not been built yet. Semantic search will not be available for this task. " +
|
||||
"File-based search tools will be used instead.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nextUserContent = userContent
|
||||
let includeFileDetails = true
|
||||
|
||||
|
|
|
|||
422
src/core/tools/__tests__/codebaseSearchTool.spec.ts
Normal file
422
src/core/tools/__tests__/codebaseSearchTool.spec.ts
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { codebaseSearchTool } from "../codebaseSearchTool"
|
||||
import { CodeIndexManager } from "../../../services/code-index/manager"
|
||||
import { Task } from "../../task/Task"
|
||||
import { formatResponse } from "../../prompts/responses"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../../../services/code-index/manager")
|
||||
vi.mock("../../prompts/responses")
|
||||
vi.mock("vscode", () => ({
|
||||
workspace: {
|
||||
asRelativePath: vi.fn((path: string) => path.replace("/test/", "")),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("codebaseSearchTool", () => {
|
||||
let mockTask: any
|
||||
let mockAskApproval: any
|
||||
let mockHandleError: any
|
||||
let mockPushToolResult: any
|
||||
let mockRemoveClosingTag: any
|
||||
let mockCodeIndexManager: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup mock task
|
||||
mockTask = {
|
||||
cwd: "/test/workspace",
|
||||
consecutiveMistakeCount: 0,
|
||||
providerRef: {
|
||||
deref: vi.fn().mockReturnValue({
|
||||
context: {},
|
||||
}),
|
||||
},
|
||||
ask: vi.fn(),
|
||||
say: vi.fn(),
|
||||
sayAndCreateMissingParamError: vi.fn(),
|
||||
}
|
||||
|
||||
// Setup mock functions
|
||||
mockAskApproval = vi.fn().mockResolvedValue(true)
|
||||
mockHandleError = vi.fn()
|
||||
mockPushToolResult = vi.fn()
|
||||
mockRemoveClosingTag = vi.fn((tag, value) => value)
|
||||
|
||||
// Setup mock CodeIndexManager
|
||||
mockCodeIndexManager = {
|
||||
isFeatureEnabled: true,
|
||||
isFeatureConfigured: true,
|
||||
isInitialized: true,
|
||||
getCurrentStatus: vi.fn(),
|
||||
searchIndex: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mocked(CodeIndexManager.getInstance).mockReturnValue(mockCodeIndexManager)
|
||||
vi.mocked(formatResponse.toolDenied).mockReturnValue("Tool denied")
|
||||
vi.mocked(formatResponse.missingToolParameterError).mockReturnValue("Missing parameter")
|
||||
})
|
||||
|
||||
describe("indexing status checks", () => {
|
||||
it("should throw error when indexing is in progress", async () => {
|
||||
// Arrange
|
||||
mockCodeIndexManager.getCurrentStatus.mockReturnValue({
|
||||
systemStatus: "Indexing",
|
||||
processedItems: 50,
|
||||
totalItems: 100,
|
||||
currentItemUnit: "blocks",
|
||||
})
|
||||
|
||||
const block = {
|
||||
type: "tool_use" as const,
|
||||
name: "codebase_search" as const,
|
||||
params: {
|
||||
query: "test query",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
await codebaseSearchTool(
|
||||
mockTask,
|
||||
block,
|
||||
mockAskApproval,
|
||||
mockHandleError,
|
||||
mockPushToolResult,
|
||||
mockRemoveClosingTag,
|
||||
)
|
||||
|
||||
expect(mockHandleError).toHaveBeenCalledWith(
|
||||
"codebase_search",
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("Code indexing is currently in progress (50% complete)"),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw error when index is in standby state", async () => {
|
||||
// Arrange
|
||||
mockCodeIndexManager.getCurrentStatus.mockReturnValue({
|
||||
systemStatus: "Standby",
|
||||
processedItems: 0,
|
||||
totalItems: 0,
|
||||
})
|
||||
|
||||
const block = {
|
||||
type: "tool_use" as const,
|
||||
name: "codebase_search" as const,
|
||||
params: {
|
||||
query: "test query",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
await codebaseSearchTool(
|
||||
mockTask,
|
||||
block,
|
||||
mockAskApproval,
|
||||
mockHandleError,
|
||||
mockPushToolResult,
|
||||
mockRemoveClosingTag,
|
||||
)
|
||||
|
||||
expect(mockHandleError).toHaveBeenCalledWith(
|
||||
"codebase_search",
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("Code index is not ready (status: Standby)"),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw error when index is in error state", async () => {
|
||||
// Arrange
|
||||
mockCodeIndexManager.getCurrentStatus.mockReturnValue({
|
||||
systemStatus: "Error",
|
||||
processedItems: 0,
|
||||
totalItems: 0,
|
||||
message: "Index failed",
|
||||
})
|
||||
|
||||
const block = {
|
||||
type: "tool_use" as const,
|
||||
name: "codebase_search" as const,
|
||||
params: {
|
||||
query: "test query",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
await codebaseSearchTool(
|
||||
mockTask,
|
||||
block,
|
||||
mockAskApproval,
|
||||
mockHandleError,
|
||||
mockPushToolResult,
|
||||
mockRemoveClosingTag,
|
||||
)
|
||||
|
||||
expect(mockHandleError).toHaveBeenCalledWith(
|
||||
"codebase_search",
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("Code index is not ready (status: Error)"),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should proceed with search when index is ready", async () => {
|
||||
// Arrange
|
||||
mockCodeIndexManager.getCurrentStatus.mockReturnValue({
|
||||
systemStatus: "Indexed",
|
||||
processedItems: 100,
|
||||
totalItems: 100,
|
||||
})
|
||||
|
||||
mockCodeIndexManager.searchIndex.mockResolvedValue([
|
||||
{
|
||||
score: 0.95,
|
||||
payload: {
|
||||
filePath: "/test/file.ts",
|
||||
startLine: 10,
|
||||
endLine: 20,
|
||||
codeChunk: "test code",
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const block = {
|
||||
type: "tool_use" as const,
|
||||
name: "codebase_search" as const,
|
||||
params: {
|
||||
query: "test query",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
// Mock say method to capture the result
|
||||
mockTask.say = vi.fn()
|
||||
|
||||
// Act
|
||||
await codebaseSearchTool(
|
||||
mockTask,
|
||||
block,
|
||||
mockAskApproval,
|
||||
mockHandleError,
|
||||
mockPushToolResult,
|
||||
mockRemoveClosingTag,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(mockCodeIndexManager.searchIndex).toHaveBeenCalledWith("test query", undefined)
|
||||
expect(mockPushToolResult).toHaveBeenCalled()
|
||||
expect(mockHandleError).not.toHaveBeenCalled()
|
||||
|
||||
// Verify the result was pushed with correct format
|
||||
const pushCall = mockPushToolResult.mock.calls[0][0]
|
||||
expect(pushCall).toContain("Query: test query")
|
||||
expect(pushCall).toContain("file.ts")
|
||||
expect(pushCall).toContain("Score: 0.95")
|
||||
expect(pushCall).toContain("Lines: 10-20")
|
||||
expect(pushCall).toContain("test code")
|
||||
})
|
||||
|
||||
it("should calculate progress percentage correctly", async () => {
|
||||
// Arrange
|
||||
mockCodeIndexManager.getCurrentStatus.mockReturnValue({
|
||||
systemStatus: "Indexing",
|
||||
processedItems: 75,
|
||||
totalItems: 150,
|
||||
currentItemUnit: "files",
|
||||
})
|
||||
|
||||
const block = {
|
||||
type: "tool_use" as const,
|
||||
name: "codebase_search" as const,
|
||||
params: {
|
||||
query: "test query",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
// Act
|
||||
await codebaseSearchTool(
|
||||
mockTask,
|
||||
block,
|
||||
mockAskApproval,
|
||||
mockHandleError,
|
||||
mockPushToolResult,
|
||||
mockRemoveClosingTag,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(mockHandleError).toHaveBeenCalledWith(
|
||||
"codebase_search",
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("50% complete"),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle zero total items when calculating progress", async () => {
|
||||
// Arrange
|
||||
mockCodeIndexManager.getCurrentStatus.mockReturnValue({
|
||||
systemStatus: "Indexing",
|
||||
processedItems: 0,
|
||||
totalItems: 0,
|
||||
currentItemUnit: "blocks",
|
||||
})
|
||||
|
||||
const block = {
|
||||
type: "tool_use" as const,
|
||||
name: "codebase_search" as const,
|
||||
params: {
|
||||
query: "test query",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
// Act
|
||||
await codebaseSearchTool(
|
||||
mockTask,
|
||||
block,
|
||||
mockAskApproval,
|
||||
mockHandleError,
|
||||
mockPushToolResult,
|
||||
mockRemoveClosingTag,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(mockHandleError).toHaveBeenCalledWith(
|
||||
"codebase_search",
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("0% complete"),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("existing functionality", () => {
|
||||
beforeEach(() => {
|
||||
// Set index to ready state for existing functionality tests
|
||||
mockCodeIndexManager.getCurrentStatus.mockReturnValue({
|
||||
systemStatus: "Indexed",
|
||||
processedItems: 100,
|
||||
totalItems: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle missing query parameter", async () => {
|
||||
// Arrange
|
||||
const block = {
|
||||
type: "tool_use" as const,
|
||||
name: "codebase_search" as const,
|
||||
params: {},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
mockTask.sayAndCreateMissingParamError.mockResolvedValue("error message")
|
||||
|
||||
// Act
|
||||
await codebaseSearchTool(
|
||||
mockTask,
|
||||
block,
|
||||
mockAskApproval,
|
||||
mockHandleError,
|
||||
mockPushToolResult,
|
||||
mockRemoveClosingTag,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("codebase_search", "query")
|
||||
expect(mockPushToolResult).toHaveBeenCalledWith("error message")
|
||||
})
|
||||
|
||||
it("should handle user denial", async () => {
|
||||
// Arrange
|
||||
mockAskApproval.mockResolvedValue(false)
|
||||
|
||||
const block = {
|
||||
type: "tool_use" as const,
|
||||
name: "codebase_search" as const,
|
||||
params: {
|
||||
query: "test query",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
// Act
|
||||
await codebaseSearchTool(
|
||||
mockTask,
|
||||
block,
|
||||
mockAskApproval,
|
||||
mockHandleError,
|
||||
mockPushToolResult,
|
||||
mockRemoveClosingTag,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(mockPushToolResult).toHaveBeenCalledWith("Tool denied")
|
||||
expect(mockCodeIndexManager.searchIndex).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle empty search results", async () => {
|
||||
// Arrange
|
||||
mockCodeIndexManager.searchIndex.mockResolvedValue([])
|
||||
|
||||
const block = {
|
||||
type: "tool_use" as const,
|
||||
name: "codebase_search" as const,
|
||||
params: {
|
||||
query: "test query",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
// Act
|
||||
await codebaseSearchTool(
|
||||
mockTask,
|
||||
block,
|
||||
mockAskApproval,
|
||||
mockHandleError,
|
||||
mockPushToolResult,
|
||||
mockRemoveClosingTag,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(mockPushToolResult).toHaveBeenCalledWith(
|
||||
'No relevant code snippets found for the query: "test query"',
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle search with directory prefix", async () => {
|
||||
// Arrange
|
||||
mockCodeIndexManager.searchIndex.mockResolvedValue([])
|
||||
|
||||
const block = {
|
||||
type: "tool_use" as const,
|
||||
name: "codebase_search" as const,
|
||||
params: {
|
||||
query: "test query",
|
||||
path: "src/components",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
// Act
|
||||
await codebaseSearchTool(
|
||||
mockTask,
|
||||
block,
|
||||
mockAskApproval,
|
||||
mockHandleError,
|
||||
mockPushToolResult,
|
||||
mockRemoveClosingTag,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(mockCodeIndexManager.searchIndex).toHaveBeenCalledWith("test query", "src/components")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -17,7 +17,7 @@ export async function codebaseSearchTool(
|
|||
removeClosingTag: RemoveClosingTag,
|
||||
) {
|
||||
const toolName = "codebase_search"
|
||||
const workspacePath = (cline.cwd && cline.cwd.trim() !== '') ? cline.cwd : getWorkspacePath()
|
||||
const workspacePath = cline.cwd && cline.cwd.trim() !== "" ? cline.cwd : getWorkspacePath()
|
||||
|
||||
if (!workspacePath) {
|
||||
// This case should ideally not happen if Cline is initialized correctly
|
||||
|
|
@ -82,6 +82,26 @@ export async function codebaseSearchTool(
|
|||
throw new Error("Code Indexing is not configured (Missing OpenAI Key or Qdrant URL).")
|
||||
}
|
||||
|
||||
// Check if indexing is in progress
|
||||
const status = manager.getCurrentStatus()
|
||||
if (status.systemStatus === "Indexing") {
|
||||
const progressPercentage =
|
||||
status.totalItems > 0 ? Math.round((status.processedItems / status.totalItems) * 100) : 0
|
||||
throw new Error(
|
||||
`Code indexing is currently in progress (${progressPercentage}% complete). ` +
|
||||
`Please wait for indexing to complete before using semantic search, or use file-based search tools instead.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Check if index is ready
|
||||
if (status.systemStatus === "Standby" || status.systemStatus === "Error") {
|
||||
throw new Error(
|
||||
`Code index is not ready (status: ${status.systemStatus}). ` +
|
||||
`The index needs to be built before semantic search can be used. ` +
|
||||
`Please ensure indexing is enabled and configured, then wait for the initial index to build.`,
|
||||
)
|
||||
}
|
||||
|
||||
const searchResults: VectorStoreSearchResult[] = await manager.searchIndex(query, directoryPrefix)
|
||||
|
||||
// 3. Format and push results
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue