mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-10 22:41:14 +00:00
feat: add prompt-based hooks for agent lifecycle events
Implements prompt-based hooks that allow a smaller/different model to step in at specific agent lifecycle events and provide read-only advisory output. Hook events supported: - PreToolUse: fires before tool execution (with optional tool name matcher) - PostToolUse: fires after tool execution completes - Stop: fires when attempt_completion is invoked Configuration via .roo/hooks.json (project) and ~/.roo/hooks.json (global), using a Claude Code-compatible format. Hooks are read-only (no tool access) and receive conversation context. Uses the active profile API configuration. New files: - src/shared/hooks.ts: types, interfaces, and validation - src/services/hooks/HooksManager.ts: config loading with file watchers - src/services/hooks/HookExecutor.ts: hook prompt building and execution - src/shared/__tests__/hooks.spec.ts: 18 validation tests - src/services/hooks/__tests__/HookExecutor.spec.ts: 15 executor tests Modified files: - packages/types/src/message.ts: added hook_output ClineSay type - src/core/webview/ClineProvider.ts: HooksManager initialization - src/core/assistant-message/presentAssistantMessage.ts: PreToolUse/PostToolUse hooks - src/core/tools/AttemptCompletionTool.ts: Stop hook Addresses #11504
This commit is contained in:
parent
dc243e4cf9
commit
d8b53b814a
10 changed files with 939 additions and 0 deletions
|
|
@ -170,6 +170,7 @@ export const clineSays = [
|
|||
"user_edit_todos",
|
||||
"too_many_tools_warning",
|
||||
"tool",
|
||||
"hook_output",
|
||||
] as const
|
||||
|
||||
export const clineSaySchema = z.enum(clineSays)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { serializeError } from "serialize-error"
|
||||
import { executeHooks, formatHookResults } from "../../services/hooks/HookExecutor"
|
||||
import type { HookContext } from "../../shared/hooks"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types"
|
||||
|
|
@ -675,6 +677,30 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
}
|
||||
}
|
||||
|
||||
// === PreToolUse Hook ===
|
||||
if (!block.partial) {
|
||||
try {
|
||||
const hooksManager = cline.providerRef.deref()?.getHooksManager()
|
||||
if (hooksManager?.hasHooksForEvent("PreToolUse")) {
|
||||
const matchingHooks = hooksManager.getMatchingHooks("PreToolUse", block.name)
|
||||
if (matchingHooks.length > 0) {
|
||||
const hookContext: HookContext = {
|
||||
event: "PreToolUse",
|
||||
toolName: block.name,
|
||||
toolInput: block.nativeArgs || block.params,
|
||||
}
|
||||
const hookResults = await executeHooks(matchingHooks, hookContext, cline.apiConfiguration)
|
||||
const hookOutput = formatHookResults(hookResults)
|
||||
if (hookOutput) {
|
||||
await cline.say("hook_output", `PreToolUse hook for ${block.name}:\n${hookOutput}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (hookError) {
|
||||
console.warn(`[presentAssistantMessage] PreToolUse hook error:`, hookError)
|
||||
}
|
||||
}
|
||||
|
||||
switch (block.name) {
|
||||
case "write_to_file":
|
||||
await checkpointSaveAndMark(cline)
|
||||
|
|
@ -917,6 +943,38 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
}
|
||||
}
|
||||
|
||||
// === PostToolUse Hook ===
|
||||
if (!block.partial) {
|
||||
try {
|
||||
const hooksManager = cline.providerRef.deref()?.getHooksManager()
|
||||
if (hooksManager?.hasHooksForEvent("PostToolUse")) {
|
||||
const matchingHooks = hooksManager.getMatchingHooks("PostToolUse", block.name)
|
||||
if (matchingHooks.length > 0) {
|
||||
// Get the last tool result text for context
|
||||
const lastResult = cline.userMessageContent
|
||||
.filter(
|
||||
(b): b is import("@anthropic-ai/sdk").Anthropic.ToolResultBlockParam =>
|
||||
b.type === "tool_result",
|
||||
)
|
||||
.pop()
|
||||
const resultText = typeof lastResult?.content === "string" ? lastResult.content : ""
|
||||
const hookContext: HookContext = {
|
||||
event: "PostToolUse",
|
||||
toolName: block.name,
|
||||
toolResult: resultText.slice(0, 2000),
|
||||
}
|
||||
const hookResults = await executeHooks(matchingHooks, hookContext, cline.apiConfiguration)
|
||||
const hookOutput = formatHookResults(hookResults)
|
||||
if (hookOutput) {
|
||||
await cline.say("hook_output", `PostToolUse hook for ${block.name}:\n${hookOutput}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (hookError) {
|
||||
console.warn(`[presentAssistantMessage] PostToolUse hook error:`, hookError)
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import type { ToolUse } from "../../shared/tools"
|
|||
import { t } from "../../i18n"
|
||||
|
||||
import { BaseTool, ToolCallbacks } from "./BaseTool"
|
||||
import { executeHooks, formatHookResults } from "../../services/hooks/HookExecutor"
|
||||
import type { HookContext } from "../../shared/hooks"
|
||||
|
||||
interface AttemptCompletionParams {
|
||||
result: string
|
||||
|
|
@ -78,6 +80,25 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
|
|||
|
||||
task.consecutiveMistakeCount = 0
|
||||
|
||||
// === Stop Hook ===
|
||||
try {
|
||||
const hooksManager = task.providerRef.deref()?.getHooksManager()
|
||||
if (hooksManager?.hasHooksForEvent("Stop")) {
|
||||
const stopHooks = hooksManager.getHooksForEvent("Stop")
|
||||
const hookContext: HookContext = {
|
||||
event: "Stop",
|
||||
completionResult: result,
|
||||
}
|
||||
const hookResults = await executeHooks(stopHooks, hookContext, task.apiConfiguration)
|
||||
const hookOutput = formatHookResults(hookResults)
|
||||
if (hookOutput) {
|
||||
await task.say("hook_output", `Stop hook:\n${hookOutput}`)
|
||||
}
|
||||
}
|
||||
} catch (hookError) {
|
||||
console.warn(`[AttemptCompletionTool] Stop hook error:`, hookError)
|
||||
}
|
||||
|
||||
await task.say("completion_result", result, undefined, false)
|
||||
|
||||
// Force final token usage update before emitting TaskCompleted
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ import { CodeIndexManager } from "../../services/code-index/manager"
|
|||
import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager"
|
||||
import { MdmService } from "../../services/mdm/MdmService"
|
||||
import { SkillsManager } from "../../services/skills/SkillsManager"
|
||||
import { HooksManager } from "../../services/hooks/HooksManager"
|
||||
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { setTtsEnabled, setTtsSpeed } from "../../utils/tts"
|
||||
|
|
@ -142,6 +143,7 @@ export class ClineProvider
|
|||
private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class
|
||||
protected mcpHub?: McpHub // Change from private to protected
|
||||
protected skillsManager?: SkillsManager
|
||||
protected hooksManager?: HooksManager
|
||||
private marketplaceManager: MarketplaceManager
|
||||
private mdmService?: MdmService
|
||||
private taskCreationCallback: (task: Task) => void
|
||||
|
|
@ -216,6 +218,12 @@ export class ClineProvider
|
|||
this.log(`Failed to initialize Skills Manager: ${error}`)
|
||||
})
|
||||
|
||||
// Initialize Hooks Manager for prompt-based hooks
|
||||
this.hooksManager = new HooksManager(this.cwd)
|
||||
this.hooksManager.initialize().catch((error) => {
|
||||
this.log(`Failed to initialize Hooks Manager: ${error}`)
|
||||
})
|
||||
|
||||
this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager)
|
||||
|
||||
// Forward <most> task events to the provider.
|
||||
|
|
@ -664,6 +672,7 @@ export class ClineProvider
|
|||
await this.mcpHub?.unregisterClient()
|
||||
this.mcpHub = undefined
|
||||
await this.skillsManager?.dispose()
|
||||
this.hooksManager?.dispose()
|
||||
this.skillsManager = undefined
|
||||
this.marketplaceManager?.cleanup()
|
||||
this.customModesManager?.dispose()
|
||||
|
|
@ -2717,6 +2726,10 @@ export class ClineProvider
|
|||
return this.skillsManager
|
||||
}
|
||||
|
||||
public getHooksManager(): HooksManager | undefined {
|
||||
return this.hooksManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current state is compliant with MDM policy
|
||||
* @returns true if compliant or no MDM policy exists, false if MDM policy exists and user is non-compliant
|
||||
|
|
|
|||
147
src/services/hooks/HookExecutor.ts
Normal file
147
src/services/hooks/HookExecutor.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* HookExecutor runs prompt-based hooks by sending the hook prompt (with context)
|
||||
* to the configured API provider via the singleCompletionHandler.
|
||||
*
|
||||
* Hooks are read-only: they have no tool access and can only produce advisory text.
|
||||
* The advisory text is injected into the conversation as informational context.
|
||||
*/
|
||||
|
||||
import type { ProviderSettings } from "@roo-code/types"
|
||||
|
||||
import { singleCompletionHandler } from "../../utils/single-completion-handler"
|
||||
import type { HookDefinition, HookEventName, HookContext, HookResult } from "../../shared/hooks"
|
||||
|
||||
/**
|
||||
* Builds a full prompt string from the hook definition and context.
|
||||
*/
|
||||
export function buildHookPrompt(hook: HookDefinition, context: HookContext): string {
|
||||
const parts: string[] = []
|
||||
|
||||
parts.push("You are a hook that runs at a specific point in an AI coding assistant's workflow.")
|
||||
parts.push("Your role is advisory only - you cannot use tools or take actions.")
|
||||
parts.push("Provide concise, actionable feedback based on the context below.")
|
||||
parts.push("")
|
||||
|
||||
parts.push(`## Event: ${context.event}`)
|
||||
parts.push("")
|
||||
|
||||
if (context.toolName) {
|
||||
parts.push(`**Tool:** ${context.toolName}`)
|
||||
}
|
||||
|
||||
if (context.toolInput) {
|
||||
parts.push(`**Tool Input:**`)
|
||||
parts.push("```json")
|
||||
try {
|
||||
parts.push(JSON.stringify(context.toolInput, null, 2))
|
||||
} catch {
|
||||
parts.push("(unable to serialize tool input)")
|
||||
}
|
||||
parts.push("```")
|
||||
}
|
||||
|
||||
if (context.toolResult) {
|
||||
parts.push(`**Tool Result:**`)
|
||||
// Truncate very long results
|
||||
const maxResultLen = 2000
|
||||
const truncated =
|
||||
context.toolResult.length > maxResultLen
|
||||
? context.toolResult.slice(0, maxResultLen) + "\n... (truncated)"
|
||||
: context.toolResult
|
||||
parts.push(truncated)
|
||||
}
|
||||
|
||||
if (context.completionResult) {
|
||||
parts.push(`**Completion Result:**`)
|
||||
parts.push(context.completionResult)
|
||||
}
|
||||
|
||||
if (context.conversationSummary) {
|
||||
parts.push("")
|
||||
parts.push("## Recent Conversation Context")
|
||||
// Truncate to keep within reasonable limits
|
||||
const maxContextLen = 4000
|
||||
const truncated =
|
||||
context.conversationSummary.length > maxContextLen
|
||||
? context.conversationSummary.slice(0, maxContextLen) + "\n... (truncated)"
|
||||
: context.conversationSummary
|
||||
parts.push(truncated)
|
||||
}
|
||||
|
||||
parts.push("")
|
||||
parts.push("## Your Task")
|
||||
parts.push(hook.prompt)
|
||||
|
||||
return parts.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a single hook and returns the result.
|
||||
*/
|
||||
export async function executeHook(
|
||||
hook: HookDefinition,
|
||||
context: HookContext,
|
||||
apiConfiguration: ProviderSettings,
|
||||
hookIndex: number,
|
||||
): Promise<HookResult | null> {
|
||||
try {
|
||||
const prompt = buildHookPrompt(hook, context)
|
||||
const output = await singleCompletionHandler(apiConfiguration, prompt)
|
||||
|
||||
if (!output || output.trim().length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
output: output.trim(),
|
||||
event: context.event,
|
||||
hookIndex,
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[HookExecutor] Failed to execute hook (${context.event}[${hookIndex}]):`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes all matching hooks for an event and returns their combined results.
|
||||
* Hooks are executed sequentially to avoid overwhelming the API.
|
||||
*/
|
||||
export async function executeHooks(
|
||||
hooks: HookDefinition[],
|
||||
context: HookContext,
|
||||
apiConfiguration: ProviderSettings,
|
||||
): Promise<HookResult[]> {
|
||||
const results: HookResult[] = []
|
||||
|
||||
for (let i = 0; i < hooks.length; i++) {
|
||||
const result = await executeHook(hooks[i], context, apiConfiguration, i)
|
||||
|
||||
if (result) {
|
||||
results.push(result)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats hook results into a text block that can be injected into the
|
||||
* conversation context.
|
||||
*/
|
||||
export function formatHookResults(results: HookResult[]): string {
|
||||
if (results.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
parts.push("[Hook Advisory Output]")
|
||||
|
||||
for (const result of results) {
|
||||
parts.push(result.output)
|
||||
}
|
||||
|
||||
parts.push("[End Hook Advisory Output]")
|
||||
|
||||
return parts.join("\n\n")
|
||||
}
|
||||
153
src/services/hooks/HooksManager.ts
Normal file
153
src/services/hooks/HooksManager.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* HooksManager is responsible for loading and merging hook configurations from
|
||||
* project-level (.roo/hooks.json) and global-level (~/.roo/hooks.json) files.
|
||||
*
|
||||
* It watches for file changes and reloads automatically.
|
||||
*/
|
||||
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import fs from "fs/promises"
|
||||
|
||||
import { type HooksConfig, type HookEventName, type HookDefinition, validateHooksConfig } from "../../shared/hooks"
|
||||
import { getGlobalRooDirectory } from "../roo-config"
|
||||
|
||||
export class HooksManager implements vscode.Disposable {
|
||||
private projectConfig: HooksConfig | null = null
|
||||
private globalConfig: HooksConfig | null = null
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private cwd: string
|
||||
|
||||
constructor(cwd: string) {
|
||||
this.cwd = cwd
|
||||
this.setupFileWatchers()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the manager by loading both project and global configs.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await Promise.all([this.loadProjectConfig(), this.loadGlobalConfig()])
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the merged hooks for a given event. Project hooks take precedence
|
||||
* and are appended after global hooks (so they run last and can override).
|
||||
*/
|
||||
getHooksForEvent(event: HookEventName): HookDefinition[] {
|
||||
const globalHooks = this.globalConfig?.hooks[event] ?? []
|
||||
const projectHooks = this.projectConfig?.hooks[event] ?? []
|
||||
return [...globalHooks, ...projectHooks]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there are any hooks configured for the given event.
|
||||
*/
|
||||
hasHooksForEvent(event: HookEventName): boolean {
|
||||
return this.getHooksForEvent(event).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets hooks that match a specific tool name for PreToolUse / PostToolUse events.
|
||||
*/
|
||||
getMatchingHooks(event: HookEventName, toolName?: string): HookDefinition[] {
|
||||
const hooks = this.getHooksForEvent(event)
|
||||
|
||||
if (!toolName) {
|
||||
return hooks
|
||||
}
|
||||
|
||||
return hooks.filter((hook) => {
|
||||
if (!hook.matcher) {
|
||||
return true // No matcher means match all tools
|
||||
}
|
||||
|
||||
try {
|
||||
const regex = new RegExp(hook.matcher)
|
||||
return regex.test(toolName)
|
||||
} catch {
|
||||
return false // Invalid regex, skip this hook
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the project-level hooks.json path.
|
||||
*/
|
||||
getProjectHooksPath(): string {
|
||||
return path.join(this.cwd, ".roo", "hooks.json")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the global-level hooks.json path.
|
||||
*/
|
||||
getGlobalHooksPath(): string {
|
||||
return path.join(getGlobalRooDirectory(), "hooks.json")
|
||||
}
|
||||
|
||||
private async loadProjectConfig(): Promise<void> {
|
||||
this.projectConfig = await this.loadConfigFromPath(this.getProjectHooksPath())
|
||||
}
|
||||
|
||||
private async loadGlobalConfig(): Promise<void> {
|
||||
this.globalConfig = await this.loadConfigFromPath(this.getGlobalHooksPath())
|
||||
}
|
||||
|
||||
private async loadConfigFromPath(filePath: string): Promise<HooksConfig | null> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const validated = validateHooksConfig(parsed)
|
||||
|
||||
if (!validated) {
|
||||
console.warn(`[HooksManager] Invalid hooks config at ${filePath}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return validated
|
||||
} catch (error: unknown) {
|
||||
// File not found is expected and not an error
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return null
|
||||
}
|
||||
|
||||
console.warn(`[HooksManager] Failed to load hooks config from ${filePath}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private setupFileWatchers(): void {
|
||||
// Watch project-level hooks.json
|
||||
const projectPattern = new vscode.RelativePattern(this.cwd, ".roo/hooks.json")
|
||||
const projectWatcher = vscode.workspace.createFileSystemWatcher(projectPattern)
|
||||
|
||||
projectWatcher.onDidChange(() => this.loadProjectConfig())
|
||||
projectWatcher.onDidCreate(() => this.loadProjectConfig())
|
||||
projectWatcher.onDidDelete(() => {
|
||||
this.projectConfig = null
|
||||
})
|
||||
|
||||
this.disposables.push(projectWatcher)
|
||||
|
||||
// Watch global-level hooks.json
|
||||
const globalDir = getGlobalRooDirectory()
|
||||
const globalPattern = new vscode.RelativePattern(globalDir, "hooks.json")
|
||||
const globalWatcher = vscode.workspace.createFileSystemWatcher(globalPattern)
|
||||
|
||||
globalWatcher.onDidChange(() => this.loadGlobalConfig())
|
||||
globalWatcher.onDidCreate(() => this.loadGlobalConfig())
|
||||
globalWatcher.onDidDelete(() => {
|
||||
this.globalConfig = null
|
||||
})
|
||||
|
||||
this.disposables.push(globalWatcher)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const disposable of this.disposables) {
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
this.disposables = []
|
||||
}
|
||||
}
|
||||
214
src/services/hooks/__tests__/HookExecutor.spec.ts
Normal file
214
src/services/hooks/__tests__/HookExecutor.spec.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { buildHookPrompt, executeHook, executeHooks, formatHookResults } from "../HookExecutor"
|
||||
import type { HookDefinition, HookContext, HookResult } from "../../../shared/hooks"
|
||||
|
||||
// Mock the singleCompletionHandler
|
||||
vi.mock("../../../utils/single-completion-handler", () => ({
|
||||
singleCompletionHandler: vi.fn(),
|
||||
}))
|
||||
|
||||
import { singleCompletionHandler } from "../../../utils/single-completion-handler"
|
||||
|
||||
const mockSingleCompletionHandler = vi.mocked(singleCompletionHandler)
|
||||
|
||||
describe("buildHookPrompt", () => {
|
||||
it("should build a prompt with PreToolUse context", () => {
|
||||
const hook: HookDefinition = { prompt: "Check for security issues" }
|
||||
const context: HookContext = {
|
||||
event: "PreToolUse",
|
||||
toolName: "write_to_file",
|
||||
toolInput: { path: "src/main.ts", content: "console.log('test')" },
|
||||
}
|
||||
|
||||
const result = buildHookPrompt(hook, context)
|
||||
|
||||
expect(result).toContain("Event: PreToolUse")
|
||||
expect(result).toContain("**Tool:** write_to_file")
|
||||
expect(result).toContain("**Tool Input:**")
|
||||
expect(result).toContain("src/main.ts")
|
||||
expect(result).toContain("Check for security issues")
|
||||
})
|
||||
|
||||
it("should build a prompt with PostToolUse context", () => {
|
||||
const hook: HookDefinition = { prompt: "Summarize what happened" }
|
||||
const context: HookContext = {
|
||||
event: "PostToolUse",
|
||||
toolName: "execute_command",
|
||||
toolResult: "Command completed successfully",
|
||||
}
|
||||
|
||||
const result = buildHookPrompt(hook, context)
|
||||
|
||||
expect(result).toContain("Event: PostToolUse")
|
||||
expect(result).toContain("**Tool:** execute_command")
|
||||
expect(result).toContain("**Tool Result:**")
|
||||
expect(result).toContain("Command completed successfully")
|
||||
expect(result).toContain("Summarize what happened")
|
||||
})
|
||||
|
||||
it("should build a prompt with Stop context", () => {
|
||||
const hook: HookDefinition = { prompt: "Review the result" }
|
||||
const context: HookContext = {
|
||||
event: "Stop",
|
||||
completionResult: "Task completed successfully",
|
||||
}
|
||||
|
||||
const result = buildHookPrompt(hook, context)
|
||||
|
||||
expect(result).toContain("Event: Stop")
|
||||
expect(result).toContain("**Completion Result:**")
|
||||
expect(result).toContain("Task completed successfully")
|
||||
expect(result).toContain("Review the result")
|
||||
})
|
||||
|
||||
it("should truncate long tool results", () => {
|
||||
const hook: HookDefinition = { prompt: "Summarize" }
|
||||
const longResult = "x".repeat(3000)
|
||||
const context: HookContext = {
|
||||
event: "PostToolUse",
|
||||
toolName: "read_file",
|
||||
toolResult: longResult,
|
||||
}
|
||||
|
||||
const result = buildHookPrompt(hook, context)
|
||||
|
||||
expect(result).toContain("... (truncated)")
|
||||
expect(result.length).toBeLessThan(longResult.length)
|
||||
})
|
||||
|
||||
it("should truncate long conversation summaries", () => {
|
||||
const hook: HookDefinition = { prompt: "Analyze" }
|
||||
const longSummary = "y".repeat(5000)
|
||||
const context: HookContext = {
|
||||
event: "PreToolUse",
|
||||
conversationSummary: longSummary,
|
||||
}
|
||||
|
||||
const result = buildHookPrompt(hook, context)
|
||||
|
||||
expect(result).toContain("... (truncated)")
|
||||
})
|
||||
|
||||
it("should include advisory-only role description", () => {
|
||||
const hook: HookDefinition = { prompt: "test" }
|
||||
const context: HookContext = { event: "PreToolUse" }
|
||||
|
||||
const result = buildHookPrompt(hook, context)
|
||||
|
||||
expect(result).toContain("advisory only")
|
||||
expect(result).toContain("cannot use tools")
|
||||
})
|
||||
})
|
||||
|
||||
describe("executeHook", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should execute a hook and return the result", async () => {
|
||||
mockSingleCompletionHandler.mockResolvedValue("This looks safe to proceed.")
|
||||
|
||||
const hook: HookDefinition = { prompt: "Check security" }
|
||||
const context: HookContext = { event: "PreToolUse", toolName: "write_to_file" }
|
||||
const mockConfig = { apiProvider: "anthropic" } as any
|
||||
|
||||
const result = await executeHook(hook, context, mockConfig, 0)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.output).toBe("This looks safe to proceed.")
|
||||
expect(result!.event).toBe("PreToolUse")
|
||||
expect(result!.hookIndex).toBe(0)
|
||||
expect(mockSingleCompletionHandler).toHaveBeenCalledWith(mockConfig, expect.any(String))
|
||||
})
|
||||
|
||||
it("should return null for empty output", async () => {
|
||||
mockSingleCompletionHandler.mockResolvedValue("")
|
||||
|
||||
const hook: HookDefinition = { prompt: "Check" }
|
||||
const context: HookContext = { event: "PreToolUse" }
|
||||
|
||||
const result = await executeHook(hook, context, {} as any, 0)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null on error", async () => {
|
||||
mockSingleCompletionHandler.mockRejectedValue(new Error("API Error"))
|
||||
|
||||
const hook: HookDefinition = { prompt: "Check" }
|
||||
const context: HookContext = { event: "PreToolUse" }
|
||||
|
||||
const result = await executeHook(hook, context, {} as any, 0)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("executeHooks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should execute multiple hooks sequentially", async () => {
|
||||
mockSingleCompletionHandler.mockResolvedValueOnce("Result 1").mockResolvedValueOnce("Result 2")
|
||||
|
||||
const hooks: HookDefinition[] = [{ prompt: "Hook 1" }, { prompt: "Hook 2" }]
|
||||
const context: HookContext = { event: "PreToolUse", toolName: "write_to_file" }
|
||||
|
||||
const results = await executeHooks(hooks, context, {} as any)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].output).toBe("Result 1")
|
||||
expect(results[1].output).toBe("Result 2")
|
||||
expect(mockSingleCompletionHandler).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("should skip hooks that return null", async () => {
|
||||
mockSingleCompletionHandler
|
||||
.mockResolvedValueOnce("Result 1")
|
||||
.mockResolvedValueOnce("")
|
||||
.mockResolvedValueOnce("Result 3")
|
||||
|
||||
const hooks: HookDefinition[] = [{ prompt: "Hook 1" }, { prompt: "Hook 2" }, { prompt: "Hook 3" }]
|
||||
const context: HookContext = { event: "PreToolUse" }
|
||||
|
||||
const results = await executeHooks(hooks, context, {} as any)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].output).toBe("Result 1")
|
||||
expect(results[1].output).toBe("Result 3")
|
||||
})
|
||||
|
||||
it("should return empty array for no hooks", async () => {
|
||||
const results = await executeHooks([], { event: "PreToolUse" }, {} as any)
|
||||
expect(results).toHaveLength(0)
|
||||
expect(mockSingleCompletionHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatHookResults", () => {
|
||||
it("should return empty string for no results", () => {
|
||||
expect(formatHookResults([])).toBe("")
|
||||
})
|
||||
|
||||
it("should format single result", () => {
|
||||
const results: HookResult[] = [{ output: "Advisory: looks good", event: "PreToolUse", hookIndex: 0 }]
|
||||
|
||||
const formatted = formatHookResults(results)
|
||||
|
||||
expect(formatted).toContain("[Hook Advisory Output]")
|
||||
expect(formatted).toContain("Advisory: looks good")
|
||||
expect(formatted).toContain("[End Hook Advisory Output]")
|
||||
})
|
||||
|
||||
it("should format multiple results", () => {
|
||||
const results: HookResult[] = [
|
||||
{ output: "Result 1", event: "PreToolUse", hookIndex: 0 },
|
||||
{ output: "Result 2", event: "PreToolUse", hookIndex: 1 },
|
||||
]
|
||||
|
||||
const formatted = formatHookResults(results)
|
||||
|
||||
expect(formatted).toContain("Result 1")
|
||||
expect(formatted).toContain("Result 2")
|
||||
})
|
||||
})
|
||||
2
src/services/hooks/index.ts
Normal file
2
src/services/hooks/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { HooksManager } from "./HooksManager"
|
||||
export { executeHooks, formatHookResults, buildHookPrompt } from "./HookExecutor"
|
||||
180
src/shared/__tests__/hooks.spec.ts
Normal file
180
src/shared/__tests__/hooks.spec.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { validateHooksConfig, type HooksConfig } from "../hooks"
|
||||
|
||||
describe("validateHooksConfig", () => {
|
||||
it("should return null for null/undefined input", () => {
|
||||
expect(validateHooksConfig(null)).toBeNull()
|
||||
expect(validateHooksConfig(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for non-object input", () => {
|
||||
expect(validateHooksConfig("string")).toBeNull()
|
||||
expect(validateHooksConfig(123)).toBeNull()
|
||||
expect(validateHooksConfig(true)).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null when hooks key is missing", () => {
|
||||
expect(validateHooksConfig({})).toBeNull()
|
||||
expect(validateHooksConfig({ other: "data" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null when hooks is not an object", () => {
|
||||
expect(validateHooksConfig({ hooks: "string" })).toBeNull()
|
||||
expect(validateHooksConfig({ hooks: 123 })).toBeNull()
|
||||
})
|
||||
|
||||
it("should accept empty hooks object", () => {
|
||||
const result = validateHooksConfig({ hooks: {} })
|
||||
expect(result).toEqual({ hooks: {} })
|
||||
})
|
||||
|
||||
it("should accept valid PreToolUse hooks", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: "write_to_file|apply_diff",
|
||||
prompt: "Review this code change for security issues",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const result = validateHooksConfig(config)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hooks.PreToolUse).toHaveLength(1)
|
||||
expect(result!.hooks.PreToolUse![0].matcher).toBe("write_to_file|apply_diff")
|
||||
expect(result!.hooks.PreToolUse![0].prompt).toBe("Review this code change for security issues")
|
||||
})
|
||||
|
||||
it("should accept hooks without matcher (matches all tools)", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
prompt: "Summarize what happened",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const result = validateHooksConfig(config)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hooks.PostToolUse).toHaveLength(1)
|
||||
expect(result!.hooks.PostToolUse![0].matcher).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should accept valid Stop hooks", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
prompt: "Review the final result",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const result = validateHooksConfig(config)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hooks.Stop).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should accept multiple hooks per event", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
PreToolUse: [{ prompt: "Hook 1" }, { prompt: "Hook 2" }, { prompt: "Hook 3" }],
|
||||
},
|
||||
}
|
||||
const result = validateHooksConfig(config)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hooks.PreToolUse).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("should accept multiple event types", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
PreToolUse: [{ prompt: "Pre hook" }],
|
||||
PostToolUse: [{ prompt: "Post hook" }],
|
||||
Stop: [{ prompt: "Stop hook" }],
|
||||
},
|
||||
}
|
||||
const result = validateHooksConfig(config)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hooks.PreToolUse).toHaveLength(1)
|
||||
expect(result!.hooks.PostToolUse).toHaveLength(1)
|
||||
expect(result!.hooks.Stop).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should return null for empty prompt", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
PreToolUse: [{ prompt: "" }],
|
||||
},
|
||||
}
|
||||
expect(validateHooksConfig(config)).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for whitespace-only prompt", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
PreToolUse: [{ prompt: " " }],
|
||||
},
|
||||
}
|
||||
expect(validateHooksConfig(config)).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for non-string prompt", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
PreToolUse: [{ prompt: 123 }],
|
||||
},
|
||||
}
|
||||
expect(validateHooksConfig(config)).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for non-string matcher", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
PreToolUse: [{ prompt: "test", matcher: 123 }],
|
||||
},
|
||||
}
|
||||
expect(validateHooksConfig(config)).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for invalid regex matcher", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
PreToolUse: [{ prompt: "test", matcher: "[invalid" }],
|
||||
},
|
||||
}
|
||||
expect(validateHooksConfig(config)).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for non-array hook definitions", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
PreToolUse: "not an array",
|
||||
},
|
||||
}
|
||||
expect(validateHooksConfig(config)).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for non-object hook definition items", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
PreToolUse: ["string item"],
|
||||
},
|
||||
}
|
||||
expect(validateHooksConfig(config)).toBeNull()
|
||||
})
|
||||
|
||||
it("should ignore unknown event names", () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
PreToolUse: [{ prompt: "valid" }],
|
||||
UnknownEvent: [{ prompt: "ignored" }],
|
||||
},
|
||||
}
|
||||
const result = validateHooksConfig(config)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hooks.PreToolUse).toHaveLength(1)
|
||||
expect((result!.hooks as any).UnknownEvent).toBeUndefined()
|
||||
})
|
||||
})
|
||||
150
src/shared/hooks.ts
Normal file
150
src/shared/hooks.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Prompt-based hooks configuration and types.
|
||||
*
|
||||
* Hooks allow a smaller/different model to step in at specific agent lifecycle
|
||||
* events and provide read-only advisory output that gets injected into the
|
||||
* conversation context.
|
||||
*
|
||||
* Configuration lives in `.roo/hooks.json` (project) and `~/.roo/hooks.json`
|
||||
* (global user settings). The format is intentionally compatible with Claude
|
||||
* Code's hooks format for interoperability.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Hook event names that map to agent lifecycle points.
|
||||
*
|
||||
* - PreToolUse: Fires after a tool call is validated but before execution.
|
||||
* - PostToolUse: Fires after a tool call completes and its result is available.
|
||||
* - Stop: Fires when `attempt_completion` is invoked, before the task ends.
|
||||
*/
|
||||
export type HookEventName = "PreToolUse" | "PostToolUse" | "Stop"
|
||||
|
||||
/**
|
||||
* A single hook definition inside a hooks.json file.
|
||||
*/
|
||||
export interface HookDefinition {
|
||||
/**
|
||||
* Optional regex pattern to match against the tool name (for PreToolUse / PostToolUse).
|
||||
* If omitted the hook fires for every tool. Ignored for Stop events.
|
||||
*/
|
||||
matcher?: string
|
||||
|
||||
/**
|
||||
* The prompt to send to the hook model. The system will prepend relevant
|
||||
* conversation context automatically.
|
||||
*/
|
||||
prompt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level structure of a hooks.json configuration file.
|
||||
*/
|
||||
export interface HooksConfig {
|
||||
hooks: Partial<Record<HookEventName, HookDefinition[]>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to a hook prompt so the model has enough information
|
||||
* to produce a useful advisory response.
|
||||
*/
|
||||
export interface HookContext {
|
||||
/** The event that triggered this hook. */
|
||||
event: HookEventName
|
||||
|
||||
/** Name of the tool involved (PreToolUse / PostToolUse only). */
|
||||
toolName?: string
|
||||
|
||||
/** The tool's input parameters (PreToolUse only). */
|
||||
toolInput?: Record<string, unknown>
|
||||
|
||||
/** The tool's result text (PostToolUse only). */
|
||||
toolResult?: string
|
||||
|
||||
/** The completion result text (Stop only). */
|
||||
completionResult?: string
|
||||
|
||||
/** Recent conversation messages (trimmed for context window). */
|
||||
conversationSummary?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned after executing a hook prompt.
|
||||
*/
|
||||
export interface HookResult {
|
||||
/** The hook's advisory output text. */
|
||||
output: string
|
||||
|
||||
/** The event that produced this result. */
|
||||
event: HookEventName
|
||||
|
||||
/** Which hook definition (by index) produced this result. */
|
||||
hookIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a parsed object against the expected HooksConfig shape.
|
||||
* Returns the validated config or null if invalid.
|
||||
*/
|
||||
export function validateHooksConfig(obj: unknown): HooksConfig | null {
|
||||
if (!obj || typeof obj !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = obj as Record<string, unknown>
|
||||
|
||||
if (!candidate.hooks || typeof candidate.hooks !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const hooks = candidate.hooks as Record<string, unknown>
|
||||
const validEvents: HookEventName[] = ["PreToolUse", "PostToolUse", "Stop"]
|
||||
const result: HooksConfig = { hooks: {} }
|
||||
|
||||
for (const event of validEvents) {
|
||||
const defs = hooks[event]
|
||||
if (defs === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!Array.isArray(defs)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const validDefs: HookDefinition[] = []
|
||||
|
||||
for (const def of defs) {
|
||||
if (!def || typeof def !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const d = def as Record<string, unknown>
|
||||
|
||||
if (typeof d.prompt !== "string" || d.prompt.trim().length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hookDef: HookDefinition = { prompt: d.prompt }
|
||||
|
||||
if (d.matcher !== undefined) {
|
||||
if (typeof d.matcher !== "string") {
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate the regex
|
||||
try {
|
||||
new RegExp(d.matcher)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
hookDef.matcher = d.matcher
|
||||
}
|
||||
|
||||
validDefs.push(hookDef)
|
||||
}
|
||||
|
||||
result.hooks[event] = validDefs
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue