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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { CustomToolRegistry } from "../custom-tool-registry.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))

const TEST_FIXTURES_DIR = path.join(__dirname, "fixtures")
const TEST_FIXTURES_OVERRIDE_DIR = path.join(__dirname, "fixtures-override")

describe("CustomToolRegistry", () => {
let registry: CustomToolRegistry
Expand Down Expand Up @@ -282,4 +283,99 @@ describe("CustomToolRegistry", () => {
expect(result.loaded).toContain("cached")
}, 30000)
})

describe.sequential("loadFromDirectories", () => {
it("should load tools from multiple directories", async () => {
const result = await registry.loadFromDirectories([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])

// Should load tools from both directories.
expect(result.loaded).toContain("simple") // From both directories (override wins).
expect(result.loaded).toContain("unique_override") // Only in override directory.
expect(result.loaded).toContain("multi_toolA") // Only in fixtures directory.
}, 60000)

it("should allow later directories to override earlier ones", async () => {
await registry.loadFromDirectories([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])

// The simple tool should have the overridden description.
const simpleTool = registry.get("simple")
expect(simpleTool).toBeDefined()
expect(simpleTool?.description).toBe("Simple tool - OVERRIDDEN")
}, 60000)

it("should preserve order: first directory loaded first, second overrides", async () => {
// Load in reverse order: override first, then fixtures.
await registry.loadFromDirectories([TEST_FIXTURES_OVERRIDE_DIR, TEST_FIXTURES_DIR])

// Now the original fixtures directory should win.
const simpleTool = registry.get("simple")
expect(simpleTool).toBeDefined()
expect(simpleTool?.description).toBe("Simple tool") // Original wins when loaded second.
}, 60000)

it("should handle non-existent directories in the array", async () => {
const result = await registry.loadFromDirectories([
"/nonexistent/path",
TEST_FIXTURES_DIR,
"/another/nonexistent",
])

// Should still load from the existing directory.
expect(result.loaded).toContain("simple")
expect(result.failed).toHaveLength(1) // Only the invalid.ts from fixtures.
}, 60000)

it("should handle empty array", async () => {
const result = await registry.loadFromDirectories([])

expect(result.loaded).toHaveLength(0)
expect(result.failed).toHaveLength(0)
})

it("should combine results from all directories", async () => {
const result = await registry.loadFromDirectories([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])

// Loaded should include tools from both (with duplicates since simple is loaded twice).
// The "simple" tool is loaded from both directories.
const simpleCount = result.loaded.filter((name) => name === "simple").length
expect(simpleCount).toBe(2) // Listed twice in loaded results.
}, 60000)
})

describe.sequential("loadFromDirectoriesIfStale", () => {
it("should load tools from multiple directories when stale", async () => {
const result = await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])

expect(result.loaded).toContain("simple")
expect(result.loaded).toContain("unique_override")
}, 60000)

it("should not reload if directories are not stale", async () => {
// First load.
await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR])

// Clear tools but keep staleness tracking.
// (firstLoadSize is captured to document that tools were loaded, then cleared).
const _firstLoadSize = registry.size
registry.clear()

// Second load - should return cached tool names without reloading.
const result = await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR])

// Registry was cleared, not stale so no reload.
expect(result.loaded).toEqual([])
}, 30000)

it("should handle mixed stale and non-stale directories", async () => {
// Load from fixtures first.
await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR])

// Load from both - fixtures is not stale, override is new (stale).
const result = await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])

// Override directory tools should be loaded (it's stale/new).
expect(result.loaded).toContain("simple") // From override (stale).
expect(result.loaded).toContain("unique_override") // From override (stale).
}, 60000)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { parametersSchema, defineCustomTool } from "@roo-code/types"

// This tool has the same name as the one in fixtures/ to test override behavior.
export default defineCustomTool({
name: "simple",
description: "Simple tool - OVERRIDDEN",
parameters: parametersSchema.object({ value: parametersSchema.string().describe("The input value") }),
async execute(args: { value: string }) {
return "Overridden Result: " + args.value
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { parametersSchema, defineCustomTool } from "@roo-code/types"

// This tool only exists in fixtures-override/ to test combined loading.
export default defineCustomTool({
name: "unique_override",
description: "A unique tool only in override directory",
parameters: parametersSchema.object({ input: parametersSchema.string().describe("The input") }),
async execute(args: { input: string }) {
return "Unique: " + args.input
},
})
54 changes: 45 additions & 9 deletions packages/core/src/custom-tools/custom-tool-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,10 @@ import os from "os"

import type { CustomToolDefinition, SerializedCustomToolDefinition, CustomToolParametersSchema } from "@roo-code/types"

import type { StoredCustomTool, LoadResult } from "./types.js"
import { serializeCustomTool } from "./serialize.js"
import { runEsbuild } from "./esbuild-runner.js"

export interface LoadResult {
loaded: string[]
failed: Array<{ file: string; error: string }>
}

export interface RegistryOptions {
/** Directory for caching compiled TypeScript files. */
cacheDir?: string
Expand All @@ -33,7 +29,7 @@ export interface RegistryOptions {
}

export class CustomToolRegistry {
private tools = new Map<string, CustomToolDefinition>()
private tools = new Map<string, StoredCustomTool>()
private tsCache = new Map<string, string>()
private cacheDir: string
private nodePaths: string[]
Expand Down Expand Up @@ -78,7 +74,7 @@ export class CustomToolRegistry {
continue
}

this.tools.set(def.name, def)
this.tools.set(def.name, { ...def, source: filePath })
console.log(`[CustomToolRegistry] loaded tool ${def.name} from ${filePath}`)
result.loaded.push(def.name)
}
Expand Down Expand Up @@ -113,18 +109,58 @@ export class CustomToolRegistry {
return { loaded: this.list(), failed: [] }
}

/**
* Load all tools from multiple directories.
* Directories are processed in order, so later directories can override tools from earlier ones.
* Supports both .ts and .js files.
*
* @param toolDirs - Array of absolute paths to tools directories
* @returns LoadResult with lists of loaded and failed tools from all directories
*/
async loadFromDirectories(toolDirs: string[]): Promise<LoadResult> {
const result: LoadResult = { loaded: [], failed: [] }

for (const toolDir of toolDirs) {
const dirResult = await this.loadFromDirectory(toolDir)
result.loaded.push(...dirResult.loaded)
result.failed.push(...dirResult.failed)
}

return result
}

/**
* Load all tools from multiple directories if any has become stale.
* Directories are processed in order, so later directories can override tools from earlier ones.
*
* @param toolDirs - Array of absolute paths to tools directories
* @returns LoadResult with lists of loaded and failed tools
*/
async loadFromDirectoriesIfStale(toolDirs: string[]): Promise<LoadResult> {
const result: LoadResult = { loaded: [], failed: [] }

for (const toolDir of toolDirs) {
const dirResult = await this.loadFromDirectoryIfStale(toolDir)
result.loaded.push(...dirResult.loaded)
result.failed.push(...dirResult.failed)
}

return result
}

/**
* Register a tool directly (without loading from file).
*/
register(definition: CustomToolDefinition): void {
register(definition: CustomToolDefinition, source?: string): void {
const { name: id } = definition
const validated = this.validate(id, definition)

if (!validated) {
throw new Error(`Invalid tool definition for '${id}'`)
}

this.tools.set(id, validated)
const storedTool: StoredCustomTool = source ? { ...validated, source } : validated
this.tools.set(id, storedTool)
}

/**
Expand Down
Loading