mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-09 22:31:08 +00:00
feat: promote hooks from experimental to permanent feature
Remove hooks from experimental flags and make it a core feature: - Remove HOOKS from ExperimentId enum and experimentsSchema - Initialize HookManager unconditionally in ClineProvider - Remove experiment checks from webviewMessageHandler - Always show Hooks tab in SettingsView - Add dynamic initialization with file watchers (no restart required) - Update translations to remove restart requirement text - Add comprehensive tests for dynamic hook initialization The master 'Enable Hooks' toggle in HooksSettings controls execution.
This commit is contained in:
parent
2cac5639dc
commit
fe0e61f232
16 changed files with 489 additions and 120 deletions
|
|
@ -14,7 +14,6 @@ export const experimentIds = [
|
|||
"runSlashCommand",
|
||||
"multipleNativeToolCalls",
|
||||
"customTools",
|
||||
"hooks",
|
||||
] as const
|
||||
|
||||
export const experimentIdsSchema = z.enum(experimentIds)
|
||||
|
|
@ -33,7 +32,6 @@ export const experimentsSchema = z.object({
|
|||
runSlashCommand: z.boolean().optional(),
|
||||
multipleNativeToolCalls: z.boolean().optional(),
|
||||
customTools: z.boolean().optional(),
|
||||
hooks: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type Experiments = z.infer<typeof experimentsSchema>
|
||||
|
|
|
|||
|
|
@ -540,23 +540,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
})
|
||||
|
||||
// Initialize tool execution hooks (only if hooks experiment is enabled)
|
||||
const hooksExperimentEnabled = experiments.isEnabled(experimentsConfig ?? {}, EXPERIMENT_IDS.HOOKS)
|
||||
// Initialize tool execution hooks
|
||||
this.toolExecutionHooks = createToolExecutionHooks(
|
||||
hooksExperimentEnabled ? (provider.getHookManager() ?? null) : null,
|
||||
provider.getHookManager() ?? null,
|
||||
(status) => provider.postHookStatusToWebview(status),
|
||||
async (type, text) => {
|
||||
await this.say(type as ClineSay, text)
|
||||
},
|
||||
// Getter for global hooksEnabled state - checks both experiment and user setting
|
||||
// Getter for global hooksEnabled state
|
||||
() => {
|
||||
const experimentEnabled = experiments.isEnabled(
|
||||
provider.contextProxy.getValue("experiments") ?? {},
|
||||
EXPERIMENT_IDS.HOOKS,
|
||||
)
|
||||
// Default to true if hooksEnabled is undefined (backwards compatibility)
|
||||
const userEnabled = provider.contextProxy.getValue("hooksEnabled") ?? true
|
||||
return experimentEnabled && userEnabled
|
||||
return provider.contextProxy.getValue("hooksEnabled") ?? true
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ import { findLast } from "../../shared/array"
|
|||
import { supportPrompt } from "../../shared/support-prompt"
|
||||
import { GlobalFileNames } from "../../shared/globalFileNames"
|
||||
import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes"
|
||||
import { experimentDefault, experiments, EXPERIMENT_IDS } from "../../shared/experiments"
|
||||
import { experimentDefault } from "../../shared/experiments"
|
||||
import { formatLanguage } from "../../shared/language"
|
||||
import { WebviewMessage } from "../../shared/WebviewMessage"
|
||||
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
|
||||
|
|
@ -144,6 +144,9 @@ export class ClineProvider
|
|||
protected mcpHub?: McpHub // Change from private to protected
|
||||
protected skillsManager?: SkillsManager
|
||||
protected hookManager?: IHookManager
|
||||
private hookFileWatchers: vscode.FileSystemWatcher[] = []
|
||||
private hookReloadTimeout?: NodeJS.Timeout
|
||||
private static readonly HOOK_RELOAD_DEBOUNCE_MS = 500
|
||||
private marketplaceManager: MarketplaceManager
|
||||
private mdmService?: MdmService
|
||||
private taskCreationCallback: (task: Task) => void
|
||||
|
|
@ -623,6 +626,7 @@ export class ClineProvider
|
|||
this.mcpHub = undefined
|
||||
await this.skillsManager?.dispose()
|
||||
this.skillsManager = undefined
|
||||
this.disposeHookManager()
|
||||
this.marketplaceManager?.cleanup()
|
||||
this.customModesManager?.dispose()
|
||||
this.log("Disposed all disposables")
|
||||
|
|
@ -2654,6 +2658,7 @@ export class ClineProvider
|
|||
* Initialize the Hook Manager for lifecycle hooks.
|
||||
* This loads hooks configuration from project/.roo/hooks/ files.
|
||||
* Only initializes if the hooks experiment is enabled.
|
||||
* Sets up file watchers for automatic config reloading.
|
||||
*/
|
||||
public async initializeHookManager(): Promise<void> {
|
||||
const cwd = this.currentWorkspacePath || getWorkspacePath()
|
||||
|
|
@ -2665,12 +2670,6 @@ export class ClineProvider
|
|||
try {
|
||||
const state = await this.getState()
|
||||
|
||||
// Check if hooks experiment is enabled
|
||||
if (!experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.HOOKS)) {
|
||||
this.log("[HookManager] Hooks experiment is disabled, skipping initialization")
|
||||
return
|
||||
}
|
||||
|
||||
this.hookManager = createHookManager({
|
||||
cwd,
|
||||
mode: state?.mode,
|
||||
|
|
@ -2685,6 +2684,12 @@ export class ClineProvider
|
|||
// Load hooks configuration
|
||||
await this.hookManager.loadHooksConfig()
|
||||
this.log("[HookManager] Hooks loaded successfully")
|
||||
|
||||
// Set up file watchers for hook configuration files
|
||||
this.setupHookFileWatchers(cwd, state?.mode)
|
||||
|
||||
// Notify webview of hook state
|
||||
await this.postStateToWebview()
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`[HookManager] Failed to initialize hooks: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
|
@ -2694,6 +2699,109 @@ export class ClineProvider
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up file watchers for hook configuration files.
|
||||
* Watches .roo/hooks/, ~/.roo/hooks/, and mode-specific directories.
|
||||
*/
|
||||
private setupHookFileWatchers(cwd: string, mode?: string): void {
|
||||
// Clean up any existing watchers first
|
||||
this.disposeHookFileWatchers()
|
||||
|
||||
const watchPatterns: string[] = []
|
||||
|
||||
// Project hooks: .roo/hooks/*.{json,yaml,yml}
|
||||
const projectHooksPattern = new vscode.RelativePattern(
|
||||
vscode.Uri.file(path.join(cwd, ".roo", "hooks")),
|
||||
"*.{json,yaml,yml}",
|
||||
)
|
||||
watchPatterns.push(".roo/hooks/*.{json,yaml,yml}")
|
||||
|
||||
// Mode-specific hooks: .roo/hooks-{mode}/*.{json,yaml,yml}
|
||||
let modeHooksPattern: vscode.RelativePattern | undefined
|
||||
if (mode) {
|
||||
modeHooksPattern = new vscode.RelativePattern(
|
||||
vscode.Uri.file(path.join(cwd, ".roo", `hooks-${mode}`)),
|
||||
"*.{json,yaml,yml}",
|
||||
)
|
||||
watchPatterns.push(`.roo/hooks-${mode}/*.{json,yaml,yml}`)
|
||||
}
|
||||
|
||||
// Global hooks: ~/.roo/hooks/*.{json,yaml,yml}
|
||||
const globalHooksPattern = new vscode.RelativePattern(
|
||||
vscode.Uri.file(path.join(os.homedir(), ".roo", "hooks")),
|
||||
"*.{json,yaml,yml}",
|
||||
)
|
||||
watchPatterns.push("~/.roo/hooks/*.{json,yaml,yml}")
|
||||
|
||||
this.log(`[HookManager] Setting up file watchers for: ${watchPatterns.join(", ")}`)
|
||||
|
||||
// Create debounced reload handler
|
||||
const debouncedReload = () => {
|
||||
if (this.hookReloadTimeout) {
|
||||
clearTimeout(this.hookReloadTimeout)
|
||||
}
|
||||
this.hookReloadTimeout = setTimeout(async () => {
|
||||
this.log("[HookManager] Config file changed, reloading hooks...")
|
||||
await this.reloadHooksConfig()
|
||||
await this.postStateToWebview()
|
||||
}, ClineProvider.HOOK_RELOAD_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
// Create watchers for each pattern
|
||||
const projectWatcher = vscode.workspace.createFileSystemWatcher(projectHooksPattern)
|
||||
projectWatcher.onDidCreate(debouncedReload)
|
||||
projectWatcher.onDidChange(debouncedReload)
|
||||
projectWatcher.onDidDelete(debouncedReload)
|
||||
this.hookFileWatchers.push(projectWatcher)
|
||||
|
||||
if (modeHooksPattern) {
|
||||
const modeWatcher = vscode.workspace.createFileSystemWatcher(modeHooksPattern)
|
||||
modeWatcher.onDidCreate(debouncedReload)
|
||||
modeWatcher.onDidChange(debouncedReload)
|
||||
modeWatcher.onDidDelete(debouncedReload)
|
||||
this.hookFileWatchers.push(modeWatcher)
|
||||
}
|
||||
|
||||
const globalWatcher = vscode.workspace.createFileSystemWatcher(globalHooksPattern)
|
||||
globalWatcher.onDidCreate(debouncedReload)
|
||||
globalWatcher.onDidChange(debouncedReload)
|
||||
globalWatcher.onDidDelete(debouncedReload)
|
||||
this.hookFileWatchers.push(globalWatcher)
|
||||
|
||||
this.log(`[HookManager] File watchers set up successfully (${this.hookFileWatchers.length} watchers)`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose hook file watchers.
|
||||
*/
|
||||
private disposeHookFileWatchers(): void {
|
||||
if (this.hookReloadTimeout) {
|
||||
clearTimeout(this.hookReloadTimeout)
|
||||
this.hookReloadTimeout = undefined
|
||||
}
|
||||
|
||||
for (const watcher of this.hookFileWatchers) {
|
||||
watcher.dispose()
|
||||
}
|
||||
this.hookFileWatchers = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the Hook Manager and clean up resources.
|
||||
* Called when hooks experiment is disabled.
|
||||
*/
|
||||
public disposeHookManager(): void {
|
||||
this.log("[HookManager] Disposing hook manager...")
|
||||
|
||||
// Dispose file watchers
|
||||
this.disposeHookFileWatchers()
|
||||
|
||||
// Clear the hook manager reference
|
||||
this.hookManager = undefined
|
||||
|
||||
this.log("[HookManager] Hook manager disposed")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Hook Manager instance.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,349 @@
|
|||
// npx vitest run core/webview/__tests__/ClineProvider.hooks-dynamic-init.spec.ts
|
||||
|
||||
import type { IHookManager } from "../../../services/hooks/types"
|
||||
|
||||
// Mock vscode before importing ClineProvider
|
||||
vi.mock("vscode", () => {
|
||||
const mockFileSystemWatcher = {
|
||||
onDidCreate: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
||||
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
||||
onDidDelete: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
||||
dispose: vi.fn(),
|
||||
}
|
||||
|
||||
return {
|
||||
window: {
|
||||
showInformationMessage: vi.fn(),
|
||||
showErrorMessage: vi.fn(),
|
||||
showTextDocument: vi.fn().mockResolvedValue(undefined),
|
||||
createWebviewPanel: vi.fn(),
|
||||
},
|
||||
workspace: {
|
||||
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
|
||||
openTextDocument: vi.fn().mockResolvedValue({ uri: { fsPath: "/mock/file" } }),
|
||||
createFileSystemWatcher: vi.fn().mockReturnValue(mockFileSystemWatcher),
|
||||
getConfiguration: vi.fn().mockReturnValue({
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}),
|
||||
},
|
||||
commands: {
|
||||
executeCommand: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
Uri: {
|
||||
file: vi.fn((path: string) => ({ fsPath: path })),
|
||||
joinPath: vi.fn((_base: { fsPath: string }, ...segments: string[]) => ({
|
||||
fsPath: `${_base.fsPath}/${segments.join("/")}`,
|
||||
})),
|
||||
},
|
||||
RelativePattern: vi.fn().mockImplementation((_base: unknown, _pattern: string) => ({ _base, _pattern })),
|
||||
EventEmitter: vi.fn().mockImplementation(() => ({
|
||||
event: vi.fn(),
|
||||
fire: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
ConfigurationTarget: {
|
||||
Global: 1,
|
||||
Workspace: 2,
|
||||
},
|
||||
ViewColumn: {
|
||||
One: 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("fs/promises", () => ({
|
||||
default: {
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(""),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
readdir: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(""),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
readdir: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock("os", () => ({
|
||||
default: {
|
||||
homedir: vi.fn().mockReturnValue("/mock/home"),
|
||||
},
|
||||
homedir: vi.fn().mockReturnValue("/mock/home"),
|
||||
}))
|
||||
|
||||
vi.mock("../../../services/hooks", () => ({
|
||||
createHookManager: vi.fn().mockImplementation(() => createMockHookManager()),
|
||||
}))
|
||||
|
||||
vi.mock("../../../utils/path", () => ({
|
||||
getWorkspacePath: vi.fn().mockReturnValue("/mock/workspace"),
|
||||
}))
|
||||
|
||||
import * as vscode from "vscode"
|
||||
import { createHookManager } from "../../../services/hooks"
|
||||
|
||||
// Create mock HookManager
|
||||
const createMockHookManager = (): IHookManager => ({
|
||||
loadHooksConfig: vi.fn().mockResolvedValue({
|
||||
hooksByEvent: new Map(),
|
||||
hooksById: new Map(),
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set(),
|
||||
hasProjectHooks: false,
|
||||
}),
|
||||
reloadHooksConfig: vi.fn().mockResolvedValue(undefined),
|
||||
getEnabledHooks: vi.fn().mockReturnValue([]),
|
||||
executeHooks: vi.fn().mockResolvedValue({
|
||||
results: [],
|
||||
blocked: false,
|
||||
totalDuration: 0,
|
||||
}),
|
||||
setHookEnabled: vi.fn().mockResolvedValue(undefined),
|
||||
getHookExecutionHistory: vi.fn().mockReturnValue([]),
|
||||
getConfigSnapshot: vi.fn().mockReturnValue({
|
||||
hooksByEvent: new Map(),
|
||||
hooksById: new Map(),
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set(),
|
||||
hasProjectHooks: false,
|
||||
}),
|
||||
})
|
||||
|
||||
describe("ClineProvider - Hook Dynamic Initialization", () => {
|
||||
let mockHookManager: IHookManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHookManager = createMockHookManager()
|
||||
vi.mocked(createHookManager).mockReturnValue(mockHookManager)
|
||||
vi.mocked(vscode.workspace.createFileSystemWatcher).mockClear()
|
||||
})
|
||||
|
||||
describe("initializeHookManager", () => {
|
||||
it("should always initialize hook manager (hooks is a core feature)", async () => {
|
||||
// Create a mock provider-like object to test the logic
|
||||
const cwd = "/mock/workspace"
|
||||
const state = { mode: "code" }
|
||||
|
||||
// Hooks are always initialized - no experiment check needed
|
||||
const newHookManager = createHookManager({
|
||||
cwd,
|
||||
mode: state.mode,
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
await newHookManager.loadHooksConfig()
|
||||
|
||||
expect(createHookManager).toHaveBeenCalledWith({
|
||||
cwd,
|
||||
mode: state.mode,
|
||||
logger: expect.any(Object),
|
||||
})
|
||||
expect(newHookManager.loadHooksConfig).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should set up file watchers for hook configuration files", async () => {
|
||||
const cwd = "/mock/workspace"
|
||||
const mode = "code"
|
||||
|
||||
// Simulate setupHookFileWatchers
|
||||
// Project hooks pattern
|
||||
const projectPattern = new vscode.RelativePattern(vscode.Uri.file(`${cwd}/.roo/hooks`), "*.{json,yaml,yml}")
|
||||
|
||||
// Mode-specific hooks pattern
|
||||
const modePattern = new vscode.RelativePattern(
|
||||
vscode.Uri.file(`${cwd}/.roo/hooks-${mode}`),
|
||||
"*.{json,yaml,yml}",
|
||||
)
|
||||
|
||||
// Global hooks pattern
|
||||
const globalPattern = new vscode.RelativePattern(
|
||||
vscode.Uri.file("/mock/home/.roo/hooks"),
|
||||
"*.{json,yaml,yml}",
|
||||
)
|
||||
|
||||
// Create watchers
|
||||
vscode.workspace.createFileSystemWatcher(projectPattern)
|
||||
vscode.workspace.createFileSystemWatcher(modePattern)
|
||||
vscode.workspace.createFileSystemWatcher(globalPattern)
|
||||
|
||||
expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledTimes(3)
|
||||
expect(vscode.RelativePattern).toHaveBeenCalledWith(expect.any(Object), "*.{json,yaml,yml}")
|
||||
})
|
||||
|
||||
it("should register change event handlers on file watchers", async () => {
|
||||
const watcher = vscode.workspace.createFileSystemWatcher({} as vscode.GlobPattern)
|
||||
|
||||
watcher.onDidCreate(vi.fn())
|
||||
watcher.onDidChange(vi.fn())
|
||||
watcher.onDidDelete(vi.fn())
|
||||
|
||||
expect(watcher.onDidCreate).toHaveBeenCalled()
|
||||
expect(watcher.onDidChange).toHaveBeenCalled()
|
||||
expect(watcher.onDidDelete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("disposeHookManager", () => {
|
||||
it("should dispose all file watchers", () => {
|
||||
const hookFileWatchers: { dispose: ReturnType<typeof vi.fn> }[] = [
|
||||
{ dispose: vi.fn() },
|
||||
{ dispose: vi.fn() },
|
||||
{ dispose: vi.fn() },
|
||||
]
|
||||
|
||||
// Simulate disposeHookFileWatchers
|
||||
for (const watcher of hookFileWatchers) {
|
||||
watcher.dispose()
|
||||
}
|
||||
|
||||
expect(hookFileWatchers[0].dispose).toHaveBeenCalled()
|
||||
expect(hookFileWatchers[1].dispose).toHaveBeenCalled()
|
||||
expect(hookFileWatchers[2].dispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should clear the hook manager reference", () => {
|
||||
let hookManager: IHookManager | undefined = createMockHookManager()
|
||||
|
||||
// Simulate disposeHookManager
|
||||
hookManager = undefined
|
||||
|
||||
expect(hookManager).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should clear pending reload timeout", () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
let hookReloadTimeout: NodeJS.Timeout | undefined = setTimeout(() => {}, 500)
|
||||
|
||||
// Simulate clearing timeout in disposeHookFileWatchers
|
||||
if (hookReloadTimeout) {
|
||||
clearTimeout(hookReloadTimeout)
|
||||
hookReloadTimeout = undefined
|
||||
}
|
||||
|
||||
expect(hookReloadTimeout).toBeUndefined()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe("debounced reload", () => {
|
||||
it("should debounce multiple file change events", async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const HOOK_RELOAD_DEBOUNCE_MS = 500
|
||||
const mockReload = vi.fn()
|
||||
let hookReloadTimeout: NodeJS.Timeout | undefined
|
||||
|
||||
// Simulate debounced reload function
|
||||
const debouncedReload = () => {
|
||||
if (hookReloadTimeout) {
|
||||
clearTimeout(hookReloadTimeout)
|
||||
}
|
||||
hookReloadTimeout = setTimeout(async () => {
|
||||
await mockReload()
|
||||
}, HOOK_RELOAD_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
// Trigger multiple change events rapidly
|
||||
debouncedReload()
|
||||
debouncedReload()
|
||||
debouncedReload()
|
||||
|
||||
// Should not have called reload yet
|
||||
expect(mockReload).not.toHaveBeenCalled()
|
||||
|
||||
// Advance time by debounce duration
|
||||
await vi.advanceTimersByTimeAsync(HOOK_RELOAD_DEBOUNCE_MS)
|
||||
|
||||
// Should have called reload exactly once
|
||||
expect(mockReload).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("should reset debounce timer on each new change event", async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const HOOK_RELOAD_DEBOUNCE_MS = 500
|
||||
const mockReload = vi.fn()
|
||||
let hookReloadTimeout: NodeJS.Timeout | undefined
|
||||
|
||||
const debouncedReload = () => {
|
||||
if (hookReloadTimeout) {
|
||||
clearTimeout(hookReloadTimeout)
|
||||
}
|
||||
hookReloadTimeout = setTimeout(async () => {
|
||||
await mockReload()
|
||||
}, HOOK_RELOAD_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
// First change
|
||||
debouncedReload()
|
||||
|
||||
// Advance time partially
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
// Second change should reset the timer
|
||||
debouncedReload()
|
||||
|
||||
// Advance time by original debounce (should not trigger because timer was reset)
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(mockReload).not.toHaveBeenCalled()
|
||||
|
||||
// Advance remaining time
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(mockReload).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File Watcher Patterns", () => {
|
||||
it("should watch project hooks directory with correct pattern", () => {
|
||||
const cwd = "/test/workspace"
|
||||
const expectedPath = `${cwd}/.roo/hooks`
|
||||
const expectedPattern = "*.{json,yaml,yml}"
|
||||
|
||||
new vscode.RelativePattern(vscode.Uri.file(expectedPath), expectedPattern)
|
||||
|
||||
expect(vscode.Uri.file).toHaveBeenCalledWith(expectedPath)
|
||||
expect(vscode.RelativePattern).toHaveBeenCalledWith(expect.any(Object), expectedPattern)
|
||||
})
|
||||
|
||||
it("should watch mode-specific hooks directory when mode is provided", () => {
|
||||
const cwd = "/test/workspace"
|
||||
const mode = "architect"
|
||||
const expectedPath = `${cwd}/.roo/hooks-${mode}`
|
||||
|
||||
new vscode.RelativePattern(vscode.Uri.file(expectedPath), "*.{json,yaml,yml}")
|
||||
|
||||
expect(vscode.Uri.file).toHaveBeenCalledWith(expectedPath)
|
||||
})
|
||||
|
||||
it("should watch global hooks directory", () => {
|
||||
const homedir = "/mock/home"
|
||||
const expectedPath = `${homedir}/.roo/hooks`
|
||||
|
||||
new vscode.RelativePattern(vscode.Uri.file(expectedPath), "*.{json,yaml,yml}")
|
||||
|
||||
expect(vscode.Uri.file).toHaveBeenCalledWith(expectedPath)
|
||||
})
|
||||
|
||||
it("should support json, yaml, and yml file extensions", () => {
|
||||
const expectedPattern = "*.{json,yaml,yml}"
|
||||
new vscode.RelativePattern(vscode.Uri.file("/any/path"), expectedPattern)
|
||||
|
||||
expect(vscode.RelativePattern).toHaveBeenCalledWith(expect.any(Object), expectedPattern)
|
||||
})
|
||||
})
|
||||
|
|
@ -640,18 +640,6 @@ export const webviewMessageHandler = async (
|
|||
...oldExperiments,
|
||||
...(value as Record<ExperimentId, boolean>),
|
||||
}
|
||||
|
||||
// Check if hooks experiment was just enabled
|
||||
const newExperiments = newValue as Record<ExperimentId, boolean>
|
||||
if (
|
||||
!experiments.isEnabled(oldExperiments, EXPERIMENT_IDS.HOOKS) &&
|
||||
experiments.isEnabled(newExperiments, EXPERIMENT_IDS.HOOKS)
|
||||
) {
|
||||
// Initialize HookManager when hooks experiment is enabled
|
||||
provider.initializeHookManager().catch((error) => {
|
||||
provider.log(`Failed to initialize Hook Manager after experiment enable: ${error}`)
|
||||
})
|
||||
}
|
||||
} else if (key === "customSupportPrompts") {
|
||||
if (!value) {
|
||||
continue
|
||||
|
|
@ -3355,11 +3343,6 @@ export const webviewMessageHandler = async (
|
|||
// =====================================================================
|
||||
|
||||
case "hooksReloadConfig": {
|
||||
// Check if hooks experiment is enabled
|
||||
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
|
||||
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
|
||||
break
|
||||
}
|
||||
// Reload hooks configuration from all sources
|
||||
const hookManager = provider.getHookManager()
|
||||
if (hookManager) {
|
||||
|
|
@ -3377,11 +3360,6 @@ export const webviewMessageHandler = async (
|
|||
}
|
||||
|
||||
case "hooksSetEnabled": {
|
||||
// Check if hooks experiment is enabled
|
||||
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
|
||||
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
|
||||
break
|
||||
}
|
||||
// Enable or disable a specific hook
|
||||
const hookManager = provider.getHookManager()
|
||||
if (hookManager && message.hookId && typeof message.hookEnabled === "boolean") {
|
||||
|
|
@ -3399,11 +3377,6 @@ export const webviewMessageHandler = async (
|
|||
}
|
||||
|
||||
case "hooksSetAllEnabled": {
|
||||
// Check if hooks experiment is enabled
|
||||
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
|
||||
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
|
||||
break
|
||||
}
|
||||
// Enable or disable hooks globally via the master toggle.
|
||||
// This is stored in global state and checked before executing any hook.
|
||||
if (typeof message.hooksEnabled === "boolean") {
|
||||
|
|
@ -3421,11 +3394,6 @@ export const webviewMessageHandler = async (
|
|||
}
|
||||
|
||||
case "hooksOpenConfigFolder": {
|
||||
// Check if hooks experiment is enabled
|
||||
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
|
||||
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
|
||||
break
|
||||
}
|
||||
// Open the hooks configuration folder in VS Code
|
||||
const source = message.hooksSource ?? "project"
|
||||
try {
|
||||
|
|
@ -3456,11 +3424,6 @@ export const webviewMessageHandler = async (
|
|||
}
|
||||
|
||||
case "hooksDeleteHook": {
|
||||
// Check if hooks experiment is enabled
|
||||
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
|
||||
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
|
||||
break
|
||||
}
|
||||
const hookManager = provider.getHookManager()
|
||||
if (!hookManager || !message.hookId) {
|
||||
break
|
||||
|
|
@ -3567,11 +3530,6 @@ export const webviewMessageHandler = async (
|
|||
}
|
||||
|
||||
case "hooksOpenHookFile": {
|
||||
// Check if hooks experiment is enabled
|
||||
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
|
||||
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
|
||||
break
|
||||
}
|
||||
const { filePath: hookFilePath } = message
|
||||
if (!hookFilePath) {
|
||||
return
|
||||
|
|
@ -3595,12 +3553,6 @@ export const webviewMessageHandler = async (
|
|||
}
|
||||
|
||||
case "hooksCreateNew": {
|
||||
// Check if hooks experiment is enabled
|
||||
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
|
||||
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
const cwd = provider.cwd
|
||||
const hooksPath = path.join(cwd, ".roo", "hooks")
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ describe("experiments", () => {
|
|||
runSlashCommand: false,
|
||||
multipleNativeToolCalls: false,
|
||||
customTools: false,
|
||||
hooks: false,
|
||||
}
|
||||
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false)
|
||||
})
|
||||
|
|
@ -47,7 +46,6 @@ describe("experiments", () => {
|
|||
runSlashCommand: false,
|
||||
multipleNativeToolCalls: false,
|
||||
customTools: false,
|
||||
hooks: false,
|
||||
}
|
||||
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true)
|
||||
})
|
||||
|
|
@ -61,7 +59,6 @@ describe("experiments", () => {
|
|||
runSlashCommand: false,
|
||||
multipleNativeToolCalls: false,
|
||||
customTools: false,
|
||||
hooks: false,
|
||||
}
|
||||
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ export const EXPERIMENT_IDS = {
|
|||
RUN_SLASH_COMMAND: "runSlashCommand",
|
||||
MULTIPLE_NATIVE_TOOL_CALLS: "multipleNativeToolCalls",
|
||||
CUSTOM_TOOLS: "customTools",
|
||||
HOOKS: "hooks",
|
||||
} as const satisfies Record<string, ExperimentId>
|
||||
|
||||
type _AssertExperimentIds = AssertEqual<Equals<ExperimentId, Values<typeof EXPERIMENT_IDS>>>
|
||||
|
|
@ -27,7 +26,6 @@ export const experimentConfigsMap: Record<ExperimentKey, ExperimentConfig> = {
|
|||
RUN_SLASH_COMMAND: { enabled: false },
|
||||
MULTIPLE_NATIVE_TOOL_CALLS: { enabled: false },
|
||||
CUSTOM_TOOLS: { enabled: false },
|
||||
HOOKS: { enabled: false },
|
||||
}
|
||||
|
||||
export const experimentDefault = Object.fromEntries(
|
||||
|
|
|
|||
|
|
@ -66,18 +66,15 @@ const App = () => {
|
|||
cloudOrganizations,
|
||||
renderContext,
|
||||
mdmCompliant,
|
||||
experiments,
|
||||
} = useExtensionState()
|
||||
|
||||
// Hooks auto-reload
|
||||
// Hooks auto-reload (always enabled since hooks is a permanent feature)
|
||||
useEffect(() => {
|
||||
if (experiments?.hooks) {
|
||||
const intervalId = setInterval(() => {
|
||||
vscode.postMessage({ type: "hooksReloadConfig" })
|
||||
}, 5000)
|
||||
return () => clearInterval(intervalId)
|
||||
}
|
||||
}, [experiments?.hooks])
|
||||
const intervalId = setInterval(() => {
|
||||
vscode.postMessage({ type: "hooksReloadConfig" })
|
||||
}, 5000)
|
||||
return () => clearInterval(intervalId)
|
||||
}, [])
|
||||
|
||||
// Create a persistent state manager
|
||||
const marketplaceStateManager = useMemo(() => new MarketplaceViewStateManager(), [])
|
||||
|
|
|
|||
|
|
@ -173,14 +173,14 @@ describe("App", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("auto-reloads hooks config every 5 seconds when hooks experiment is enabled", () => {
|
||||
it("auto-reloads hooks config every 5 seconds", () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
mockUseExtensionState.mockReturnValue({
|
||||
didHydrateState: true,
|
||||
showWelcome: false,
|
||||
shouldShowAnnouncement: false,
|
||||
experiments: { hooks: true },
|
||||
experiments: {},
|
||||
language: "en",
|
||||
telemetrySetting: "enabled",
|
||||
})
|
||||
|
|
@ -212,29 +212,6 @@ describe("App", () => {
|
|||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("does not auto-reload hooks config when hooks experiment is disabled", () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
mockUseExtensionState.mockReturnValue({
|
||||
didHydrateState: true,
|
||||
showWelcome: false,
|
||||
shouldShowAnnouncement: false,
|
||||
experiments: { hooks: false },
|
||||
language: "en",
|
||||
telemetrySetting: "enabled",
|
||||
})
|
||||
|
||||
render(<AppWithProviders />)
|
||||
|
||||
// Advance time by 5s
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000)
|
||||
})
|
||||
expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: "hooksReloadConfig" })
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
window.removeEventListener("message", () => {})
|
||||
|
|
|
|||
|
|
@ -521,8 +521,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
}
|
||||
}, [])
|
||||
|
||||
const sections: { id: SectionName; icon: LucideIcon }[] = useMemo(() => {
|
||||
const allSections: { id: SectionName; icon: LucideIcon }[] = [
|
||||
const sections: { id: SectionName; icon: LucideIcon }[] = useMemo(
|
||||
() => [
|
||||
{ id: "providers", icon: Plug },
|
||||
{ id: "modes", icon: Users2 },
|
||||
{ id: "mcp", icon: Server },
|
||||
|
|
@ -539,10 +539,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
{ id: "experimental", icon: FlaskConical },
|
||||
{ id: "language", icon: Globe },
|
||||
{ id: "about", icon: Info },
|
||||
]
|
||||
// Filter out hooks section if the experiment is not enabled
|
||||
return allSections.filter((section) => section.id !== "hooks" || experiments?.hooks === true)
|
||||
}, [experiments?.hooks])
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
// Update target section logic to set active tab
|
||||
useEffect(() => {
|
||||
|
|
@ -885,8 +884,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
{/* MCP Section */}
|
||||
{renderTab === "mcp" && <McpView />}
|
||||
|
||||
{/* Hooks Section - only render if experiment is enabled */}
|
||||
{renderTab === "hooks" && experiments?.hooks === true && <HooksSettings />}
|
||||
{/* Hooks Section */}
|
||||
{renderTab === "hooks" && <HooksSettings />}
|
||||
|
||||
{/* Prompts Section */}
|
||||
{renderTab === "prompts" && (
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/ca/settings.json
generated
2
webview-ui/src/i18n/locales/ca/settings.json
generated
|
|
@ -897,7 +897,7 @@
|
|||
},
|
||||
"HOOKS": {
|
||||
"name": "Habilitar Hooks",
|
||||
"description": "Utilitza ordres de shell personalitzades per automatitzar accions abans o després de l'execució d'eines. (Cal reiniciar després de desar la configuració)"
|
||||
"description": "Utilitza ordres de shell personalitzades per automatitzar accions abans o després de l'execució d'eines."
|
||||
}
|
||||
},
|
||||
"promptCaching": {
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/de/settings.json
generated
2
webview-ui/src/i18n/locales/de/settings.json
generated
|
|
@ -897,7 +897,7 @@
|
|||
},
|
||||
"HOOKS": {
|
||||
"name": "Hooks aktivieren",
|
||||
"description": "Verwende benutzerdefinierte Shell-Befehle, um Aktionen vor oder nach der Tool-Ausführung zu automatisieren. (Neustart erforderlich nach dem Speichern der Einstellungen)"
|
||||
"description": "Verwende benutzerdefinierte Shell-Befehle, um Aktionen vor oder nach der Tool-Ausführung zu automatisieren."
|
||||
}
|
||||
},
|
||||
"promptCaching": {
|
||||
|
|
|
|||
|
|
@ -906,7 +906,7 @@
|
|||
},
|
||||
"HOOKS": {
|
||||
"name": "Enable Hooks",
|
||||
"description": "Use custom shell commands to automate actions before or after tool execution. (Restart required after saving settings)"
|
||||
"description": "Use custom shell commands to automate actions before or after tool execution."
|
||||
}
|
||||
},
|
||||
"promptCaching": {
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/es/settings.json
generated
2
webview-ui/src/i18n/locales/es/settings.json
generated
|
|
@ -897,7 +897,7 @@
|
|||
},
|
||||
"HOOKS": {
|
||||
"name": "Habilitar Hooks",
|
||||
"description": "Usa comandos de shell personalizados para automatizar acciones antes o después de la ejecución de herramientas. (Se requiere reinicio después de guardar la configuración)"
|
||||
"description": "Usa comandos de shell personalizados para automatizar acciones antes o después de la ejecución de herramientas."
|
||||
}
|
||||
},
|
||||
"promptCaching": {
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/it/settings.json
generated
2
webview-ui/src/i18n/locales/it/settings.json
generated
|
|
@ -898,7 +898,7 @@
|
|||
},
|
||||
"HOOKS": {
|
||||
"name": "Abilita Hooks",
|
||||
"description": "Usa comandi shell personalizzati per automatizzare azioni prima o dopo l'esecuzione degli strumenti. (Richiede riavvio dopo aver salvato le impostazioni)"
|
||||
"description": "Usa comandi shell personalizzati per automatizzare azioni prima o dopo l'esecuzione degli strumenti."
|
||||
}
|
||||
},
|
||||
"promptCaching": {
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/tr/settings.json
generated
2
webview-ui/src/i18n/locales/tr/settings.json
generated
|
|
@ -898,7 +898,7 @@
|
|||
},
|
||||
"HOOKS": {
|
||||
"name": "Hook'ları Etkinleştir",
|
||||
"description": "Araç yürütmeden önce veya sonra eylemleri otomatikleştirmek için özel kabuk komutları kullanın. (Ayarları kaydettikten sonra yeniden başlatma gerekli)"
|
||||
"description": "Araç yürütmeden önce veya sonra eylemleri otomatikleştirmek için özel kabuk komutları kullanın."
|
||||
}
|
||||
},
|
||||
"promptCaching": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue