diff --git a/src/core/prompts/tools/__tests__/codebase-search-readiness.spec.ts b/src/core/prompts/tools/__tests__/codebase-search-readiness.spec.ts new file mode 100644 index 0000000000..2e05f6c6cd --- /dev/null +++ b/src/core/prompts/tools/__tests__/codebase-search-readiness.spec.ts @@ -0,0 +1,160 @@ +import type OpenAI from "openai" +import { toolNamesSchema, type ModeConfig } from "@roo-code/types" +import type { CodeIndexManager } from "../../../../services/code-index/manager" +import { filterNativeToolsForMode } from "../filter-tools-for-mode" +import { resolveEffectiveToolPolicy } from "../effective-tool-policy" +import { getNativeTools } from "../native-tools" + +const tools = toolNamesSchema.enum +const ordinaryReadTools = [tools.read_file, tools.list_files, tools.search_files] +type Readiness = Pick + +function makeManager(flags: Readiness): CodeIndexManager { + // These consumers only read the public readiness getters, not manager services. + return flags as CodeIndexManager +} + +function toolNames(definitions: OpenAI.Chat.ChatCompletionTool[]) { + return definitions.flatMap((tool) => ("function" in tool ? [tool.function.name] : [])) +} + +describe("codebase_search readiness", () => { + it("excludes search without a manager but retains ordinary read tools", () => { + const policy = resolveEffectiveToolPolicy({ mode: "code" }) + const filtered = filterNativeToolsForMode(getNativeTools(), "code", [], {}) + + expect(policy.tools).not.toContain(tools.codebase_search) + expect(toolNames(filtered)).not.toContain(tools.codebase_search) + for (const tool of ordinaryReadTools) { + expect(policy.tools).toContain(tool) + expect(toolNames(filtered)).toContain(tool) + } + }) + + it.each([ + { isFeatureEnabled: false, isFeatureConfigured: false, isInitialized: false }, + { isFeatureEnabled: false, isFeatureConfigured: false, isInitialized: true }, + { isFeatureEnabled: false, isFeatureConfigured: true, isInitialized: false }, + { isFeatureEnabled: false, isFeatureConfigured: true, isInitialized: true }, + { isFeatureEnabled: true, isFeatureConfigured: false, isInitialized: false }, + { isFeatureEnabled: true, isFeatureConfigured: false, isInitialized: true }, + { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: false }, + ])( + "excludes search for enabled=$isFeatureEnabled, configured=$isFeatureConfigured, initialized=$isInitialized", + (flags) => { + const manager = makeManager(flags) + const policy = resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager }) + const filtered = filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager) + + expect(policy.tools).not.toContain(tools.codebase_search) + expect(toolNames(filtered)).not.toContain(tools.codebase_search) + for (const tool of ordinaryReadTools) { + expect(policy.tools).toContain(tool) + expect(toolNames(filtered)).toContain(tool) + } + }, + ) + + it("includes search when all three readiness conditions are met", () => { + const manager = makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const policy = resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager }) + const filtered = filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager) + + expect(policy.tools).toContain(tools.codebase_search) + expect(toolNames(filtered)).toContain(tools.codebase_search) + for (const tool of ordinaryReadTools) { + expect(policy.tools).toContain(tool) + expect(toolNames(filtered)).toContain(tool) + } + }) + + it.each(["isFeatureEnabled", "isFeatureConfigured", "isInitialized"] as const)( + "rereads %s when the same manager becomes unavailable and recovers", + (flag) => { + const flags = { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true } + const manager = makeManager(flags) + + expect(resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager }).tools).toContain( + tools.codebase_search, + ) + expect(toolNames(filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager))).toContain( + tools.codebase_search, + ) + + flags[flag] = false + + expect(resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager }).tools).not.toContain( + tools.codebase_search, + ) + expect(toolNames(filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager))).not.toContain( + tools.codebase_search, + ) + + flags[flag] = true + + expect(resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager }).tools).toContain( + tools.codebase_search, + ) + expect(toolNames(filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager))).toContain( + tools.codebase_search, + ) + }, + ) + + it("does not reuse readiness from another manager or a missing manager", () => { + const ready = makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const disabled = makeManager({ isFeatureEnabled: false, isFeatureConfigured: true, isInitialized: true }) + + expect(resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: ready }).tools).toContain( + tools.codebase_search, + ) + expect(toolNames(filterNativeToolsForMode(getNativeTools(), "code", [], {}, ready))).toContain( + tools.codebase_search, + ) + + for (const manager of [disabled, undefined]) { + expect(resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager }).tools).not.toContain( + tools.codebase_search, + ) + expect(toolNames(filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager))).not.toContain( + tools.codebase_search, + ) + } + + expect(resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: ready }).tools).toContain( + tools.codebase_search, + ) + expect(toolNames(filterNativeToolsForMode(getNativeTools(), "code", [], {}, ready))).toContain( + tools.codebase_search, + ) + }) + + it("does not grant read permissions merely because the manager is ready", () => { + const manager = makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const mode: ModeConfig = { slug: "no-read", name: "No read", roleDefinition: "No reading", groups: ["command"] } + const policy = resolveEffectiveToolPolicy({ mode: mode.slug, customModes: [mode], codeIndexManager: manager }) + const filtered = filterNativeToolsForMode(getNativeTools(), mode.slug, [mode], {}, manager) + + for (const tool of [tools.codebase_search, ...ordinaryReadTools]) { + expect(policy.tools).not.toContain(tool) + expect(toolNames(filtered)).not.toContain(tool) + } + // The mode remains usable; this is not an accidentally empty result. + expect(policy.tools).toContain(tools.execute_command) + expect(toolNames(filtered)).toContain(tools.execute_command) + }) + + it("honors explicit search disabling without disabling ordinary read tools", () => { + const manager = makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const disabledTools = [tools.codebase_search] + const policy = resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager, disabledTools }) + const filtered = filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager, { disabledTools }) + + expect(policy.tools).not.toContain(tools.codebase_search) + expect(toolNames(filtered)).not.toContain(tools.codebase_search) + for (const tool of ordinaryReadTools) { + expect(policy.tools).toContain(tool) + expect(toolNames(filtered)).toContain(tool) + } + }) +}) diff --git a/src/core/task/__tests__/build-tools-readiness.integration.spec.ts b/src/core/task/__tests__/build-tools-readiness.integration.spec.ts new file mode 100644 index 0000000000..e1f8888c9d --- /dev/null +++ b/src/core/task/__tests__/build-tools-readiness.integration.spec.ts @@ -0,0 +1,179 @@ +import type OpenAI from "openai" +import { toolNamesSchema } from "@roo-code/types" +import type { CodeIndexManager } from "../../../services/code-index/manager" +import { CodeIndexManagerRegistry } from "../../../services/code-index/code-index-manager-registry" +import { makeExtensionContext } from "../../../test-utils/vscode" +import type { ClineProvider } from "../../webview/ClineProvider" +import { buildNativeToolsArrayWithRestrictions } from "../build-tools" + +vi.mock("../../../services/code-index/code-index-manager-registry", () => ({ + CodeIndexManagerRegistry: { getOrCreate: vi.fn() }, +})) + +const tools = toolNamesSchema.enum +const ordinaryReadTools = [tools.read_file, tools.list_files, tools.search_files] + +function toolNames(definitions: OpenAI.Chat.ChatCompletionTool[]) { + return definitions.flatMap((tool) => ("function" in tool ? [tool.function.name] : [])) +} + +function makeManager(flags: Pick) { + // The real filter only consumes these public readiness getters, not manager services. + return flags as CodeIndexManager +} + +describe.each([ + { strategy: "filtered definitions", includeAllToolsWithRestrictions: false }, + { strategy: "all definitions with an allowlist", includeAllToolsWithRestrictions: true }, +])("task readiness with $strategy", ({ includeAllToolsWithRestrictions }) => { + beforeEach(() => vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockReset()) + + function makeOptions() { + const context = makeExtensionContext() + // Only context and getMcpHub are needed; constructing a webview provider is unrelated to this test. + const provider = { context, getMcpHub: () => undefined } as ClineProvider + return { + provider, + cwd: "/tasks/ready", + mode: "code", + customModes: [], + experiments: {}, + apiConfiguration: {}, + includeAllToolsWithRestrictions, + } + } + + function callable(result: Awaited>) { + return includeAllToolsWithRestrictions ? result.allowedFunctionNames : toolNames(result.tools) + } + + it("uses the task context and cwd without leaking readiness between workspaces", async () => { + const options = makeOptions() + const ready = makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const unready = makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: false }) + const managers = new Map([ + ["/tasks/ready", ready], + ["/tasks/unready", unready], + ]) + vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockImplementation((_context, cwd) => managers.get(cwd ?? "")) + + const first = await buildNativeToolsArrayWithRestrictions(options) + expect(CodeIndexManagerRegistry.getOrCreate).toHaveBeenLastCalledWith(options.provider.context, "/tasks/ready") + expect(callable(first)).toContain(tools.codebase_search) + + const other = await buildNativeToolsArrayWithRestrictions({ ...options, cwd: "/tasks/unready" }) + expect(CodeIndexManagerRegistry.getOrCreate).toHaveBeenLastCalledWith( + options.provider.context, + "/tasks/unready", + ) + if (includeAllToolsWithRestrictions) { + expect(other.allowedFunctionNames).toBeDefined() + expect(other.allowedFunctionNames).not.toContain(tools.codebase_search) + // Keep definitions for historical calls, while forbidding new calls. + expect(toolNames(other.tools)).toContain(tools.codebase_search) + } else { + expect(other.allowedFunctionNames).toBeUndefined() + expect(toolNames(other.tools)).not.toContain(tools.codebase_search) + } + for (const tool of ordinaryReadTools) { + expect(callable(other)).toContain(tool) + } + + const restored = await buildNativeToolsArrayWithRestrictions(options) + expect(CodeIndexManagerRegistry.getOrCreate).toHaveBeenLastCalledWith(options.provider.context, "/tasks/ready") + expect(callable(restored)).toContain(tools.codebase_search) + expect(CodeIndexManagerRegistry.getOrCreate).toHaveBeenCalledTimes(3) + }) + + it("omits search without a manager while retaining ordinary read tools", async () => { + vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockReturnValue(undefined) + const result = await buildNativeToolsArrayWithRestrictions({ ...makeOptions(), cwd: "/tasks/missing" }) + + if (includeAllToolsWithRestrictions) { + expect(result.allowedFunctionNames).toBeDefined() + expect(result.allowedFunctionNames).not.toContain(tools.codebase_search) + expect(toolNames(result.tools)).toContain(tools.codebase_search) + } else { + expect(result.allowedFunctionNames).toBeUndefined() + expect(toolNames(result.tools)).not.toContain(tools.codebase_search) + } + for (const tool of ordinaryReadTools) { + expect(callable(result)).toContain(tool) + } + }) + + it.each(["isFeatureEnabled", "isFeatureConfigured", "isInitialized"] as const)( + "rereads %s on subsequent builds with the same manager", + async (flag) => { + const options = makeOptions() + const flags = { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true } + vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockReturnValue(makeManager(flags)) + + const initial = await buildNativeToolsArrayWithRestrictions(options) + expect(callable(initial)).toContain(tools.codebase_search) + + flags[flag] = false + const unavailable = await buildNativeToolsArrayWithRestrictions(options) + if (includeAllToolsWithRestrictions) { + expect(unavailable.allowedFunctionNames).toBeDefined() + expect(unavailable.allowedFunctionNames).not.toContain(tools.codebase_search) + expect(toolNames(unavailable.tools)).toContain(tools.codebase_search) + } else { + expect(unavailable.allowedFunctionNames).toBeUndefined() + expect(toolNames(unavailable.tools)).not.toContain(tools.codebase_search) + } + for (const tool of ordinaryReadTools) { + expect(callable(unavailable)).toContain(tool) + } + + flags[flag] = true + const recovered = await buildNativeToolsArrayWithRestrictions(options) + expect(callable(recovered)).toContain(tools.codebase_search) + }, + ) + + it("does not grant read tools to a command-only mode even with a ready manager", async () => { + vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockReturnValue( + makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }), + ) + const result = await buildNativeToolsArrayWithRestrictions({ + ...makeOptions(), + mode: "no-read", + customModes: [{ slug: "no-read", name: "No read", roleDefinition: "No reading", groups: ["command"] }], + }) + + if (includeAllToolsWithRestrictions) { + expect(result.allowedFunctionNames).toBeDefined() + expect(toolNames(result.tools)).toContain(tools.codebase_search) + } else { + expect(result.allowedFunctionNames).toBeUndefined() + } + const callableSet = callable(result) + for (const tool of [tools.codebase_search, ...ordinaryReadTools]) { + expect(callableSet).not.toContain(tool) + } + expect(callableSet).toContain(tools.execute_command) + }) + + it("honors disabledTools with a ready manager without disabling ordinary read tools", async () => { + vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockReturnValue( + makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }), + ) + const result = await buildNativeToolsArrayWithRestrictions({ + ...makeOptions(), + disabledTools: [tools.codebase_search], + }) + + if (includeAllToolsWithRestrictions) { + expect(result.allowedFunctionNames).toBeDefined() + expect(result.allowedFunctionNames).not.toContain(tools.codebase_search) + expect(toolNames(result.tools)).toContain(tools.codebase_search) + } else { + expect(result.allowedFunctionNames).toBeUndefined() + expect(toolNames(result.tools)).not.toContain(tools.codebase_search) + } + for (const tool of ordinaryReadTools) { + expect(callable(result)).toContain(tool) + } + }) +}) diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index 9e4a8bbd0c..d839cdc646 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -1,6 +1,6 @@ // npx vitest run src/core/tools/__tests__/validateToolUse.spec.ts -import type { ModeConfig } from "@roo-code/types" +import { toolNamesSchema, type ModeConfig } from "@roo-code/types" import { modes } from "../../../shared/modes" import { TOOL_GROUPS } from "../../../shared/tools" @@ -49,6 +49,28 @@ describe("mode-validator", () => { }) describe("custom modes", () => { + it("allows codebase search in a read-only mode without manager readiness input", () => { + const mode: ModeConfig = { + slug: "read-only", + name: "Read only", + roleDefinition: "Read the codebase", + groups: ["read"], + } + + expect(isToolAllowedForMode(toolNamesSchema.enum.codebase_search, mode.slug, [mode])).toBe(true) + }) + + it("rejects codebase search in a command-only mode", () => { + const mode: ModeConfig = { + slug: "command-only", + name: "Command only", + roleDefinition: "Run commands without read tools", + groups: ["command"], + } + + expect(isToolAllowedForMode(toolNamesSchema.enum.codebase_search, mode.slug, [mode])).toBe(false) + }) + it("allows tools from custom mode configuration", () => { const customModes: ModeConfig[] = [ {