mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
feat: show hook execution output in chat
Stream hook stdout/stderr into chat with terminal-style HookExecution blocks.\n\nAdds hookExecutionOutputStatus protocol and persisted hook_execution rows with truncated summaries.
This commit is contained in:
parent
caa9fd509c
commit
6e443bf8d4
16 changed files with 1300 additions and 41 deletions
|
|
@ -0,0 +1,58 @@
|
|||
import { hookExecutionOutputStatusSchema } from "../vscode-extension-host"
|
||||
|
||||
describe("hookExecutionOutputStatusSchema", () => {
|
||||
it("accepts a valid started payload", () => {
|
||||
const result = hookExecutionOutputStatusSchema.safeParse({
|
||||
executionId: "exec_1",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
status: "started",
|
||||
command: "echo hi",
|
||||
cwd: "/project",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("requires output for status=output", () => {
|
||||
const result = hookExecutionOutputStatusSchema.safeParse({
|
||||
executionId: "exec_1",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
status: "output",
|
||||
command: "echo hi",
|
||||
cwd: "/project",
|
||||
// output missing
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("accepts a valid exited payload", () => {
|
||||
const result = hookExecutionOutputStatusSchema.safeParse({
|
||||
executionId: "exec_1",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
status: "exited",
|
||||
command: "echo hi",
|
||||
cwd: "/project",
|
||||
exitCode: 0,
|
||||
durationMs: 123,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects unknown status", () => {
|
||||
const result = hookExecutionOutputStatusSchema.safeParse({
|
||||
executionId: "exec_1",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
status: "unknown",
|
||||
command: "echo hi",
|
||||
cwd: "/project",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -180,6 +180,7 @@ export const clineSays = [
|
|||
"condense_context_error",
|
||||
"sliding_window_truncation",
|
||||
"codebase_search_result",
|
||||
"hook_execution",
|
||||
"hook_triggered",
|
||||
"user_edit_todos",
|
||||
] as const
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export interface ExtensionMessage {
|
|||
| "acceptInput"
|
||||
| "setHistoryPreviewCollapsed"
|
||||
| "commandExecutionStatus"
|
||||
| "hookExecutionOutputStatus"
|
||||
| "mcpExecutionStatus"
|
||||
| "vsCodeSetting"
|
||||
| "authenticatedUser"
|
||||
|
|
@ -195,6 +196,121 @@ export interface ExtensionMessage {
|
|||
hookExecutionStatus?: HookExecutionStatusPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* HookExecutionOutputStatusPayload
|
||||
*
|
||||
* Streaming terminal-style hook execution status updates.
|
||||
*
|
||||
* Pattern matches `commandExecutionStatus`: the payload is serialized JSON in
|
||||
* [`ExtensionMessage.text`](packages/types/src/vscode-extension-host.ts:100).
|
||||
*/
|
||||
export const hookExecutionOutputStatusSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
executionId: z.string(),
|
||||
hookId: z.string(),
|
||||
event: z.string(),
|
||||
toolName: z.string().optional(),
|
||||
status: z.literal("started"),
|
||||
command: z.string(),
|
||||
cwd: z.string(),
|
||||
shell: z.string().optional(),
|
||||
pid: z.number().optional(),
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
blockMessage: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
modified: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
executionId: z.string(),
|
||||
hookId: z.string(),
|
||||
event: z.string(),
|
||||
toolName: z.string().optional(),
|
||||
status: z.literal("output"),
|
||||
command: z.string(),
|
||||
cwd: z.string(),
|
||||
shell: z.string().optional(),
|
||||
pid: z.number().optional(),
|
||||
output: z.string(),
|
||||
exitCode: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
blockMessage: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
modified: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
executionId: z.string(),
|
||||
hookId: z.string(),
|
||||
event: z.string(),
|
||||
toolName: z.string().optional(),
|
||||
status: z.literal("exited"),
|
||||
command: z.string(),
|
||||
cwd: z.string(),
|
||||
shell: z.string().optional(),
|
||||
pid: z.number().optional(),
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
blockMessage: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
modified: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
executionId: z.string(),
|
||||
hookId: z.string(),
|
||||
event: z.string(),
|
||||
toolName: z.string().optional(),
|
||||
status: z.literal("blocked"),
|
||||
command: z.string(),
|
||||
cwd: z.string(),
|
||||
shell: z.string().optional(),
|
||||
pid: z.number().optional(),
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
blockMessage: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
modified: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
executionId: z.string(),
|
||||
hookId: z.string(),
|
||||
event: z.string(),
|
||||
toolName: z.string().optional(),
|
||||
status: z.literal("failed"),
|
||||
command: z.string(),
|
||||
cwd: z.string(),
|
||||
shell: z.string().optional(),
|
||||
pid: z.number().optional(),
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
blockMessage: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
modified: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
executionId: z.string(),
|
||||
hookId: z.string(),
|
||||
event: z.string(),
|
||||
toolName: z.string().optional(),
|
||||
status: z.literal("fallback"),
|
||||
command: z.string(),
|
||||
cwd: z.string(),
|
||||
shell: z.string().optional(),
|
||||
pid: z.number().optional(),
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
blockMessage: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
modified: z.boolean().optional(),
|
||||
}),
|
||||
])
|
||||
|
||||
export type HookExecutionOutputStatusPayload = z.infer<typeof hookExecutionOutputStatusSchema>
|
||||
|
||||
/**
|
||||
* HookExecutionStatusPayload
|
||||
* Sent when hook execution starts, completes, or fails.
|
||||
|
|
|
|||
|
|
@ -544,9 +544,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
this.toolExecutionHooks = createToolExecutionHooks(
|
||||
provider.getHookManager() ?? null,
|
||||
(status) => provider.postHookStatusToWebview(status),
|
||||
(payload) => provider.postHookExecutionOutputStatusToWebview(payload),
|
||||
async (type, text) => {
|
||||
await this.say(type as ClineSay, text)
|
||||
},
|
||||
async (messageTs, type, text) => {
|
||||
const message = this.findMessageByTimestamp(messageTs)
|
||||
if (!message || message.type !== "say" || message.say !== (type as any)) {
|
||||
return
|
||||
}
|
||||
message.text = text
|
||||
await this.saveClineMessages()
|
||||
await this.updateClineMessage(message)
|
||||
},
|
||||
// Getter for global hooksEnabled state
|
||||
() => {
|
||||
// Default to true if hooksEnabled is undefined (backwards compatibility)
|
||||
|
|
|
|||
|
|
@ -2848,6 +2848,21 @@ export class ClineProvider
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Post streaming terminal-style hook execution output status updates to webview.
|
||||
*
|
||||
* NOTE: This intentionally matches the `commandExecutionStatus` pattern:
|
||||
* the payload is serialized JSON in `ExtensionMessage.text`.
|
||||
*/
|
||||
public postHookExecutionOutputStatusToWebview(
|
||||
payload: import("@roo-code/types").HookExecutionOutputStatusPayload,
|
||||
): void {
|
||||
this.postMessageToWebview({
|
||||
type: "hookExecutionOutputStatus",
|
||||
text: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
public getSkillsManager(): SkillsManager | undefined {
|
||||
return this.skillsManager
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
import { spawn, ChildProcess } from "child_process"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import type { HookExecutionOutputStatusPayload } from "@roo-code/types"
|
||||
import { Terminal } from "../../integrations/terminal/Terminal"
|
||||
import {
|
||||
ResolvedHook,
|
||||
HookContext,
|
||||
|
|
@ -150,6 +152,14 @@ export async function executeHook(
|
|||
hook: ResolvedHook,
|
||||
context: HookContext,
|
||||
conversationHistory?: ConversationHistoryEntry[],
|
||||
options?: {
|
||||
executionId?: string
|
||||
toolName?: string
|
||||
outputStatusCallback?: (payload: HookExecutionOutputStatusPayload) => void
|
||||
outputThrottleMs?: number
|
||||
terminalOutputLineLimit?: number
|
||||
terminalOutputCharacterLimit?: number
|
||||
},
|
||||
): Promise<HookExecutionResult> {
|
||||
const startTime = Date.now()
|
||||
const timeout = (hook.timeout || DEFAULT_TIMEOUT) * 1000 // Convert to ms
|
||||
|
|
@ -168,15 +178,118 @@ export async function executeHook(
|
|||
let timedOut = false
|
||||
let resolved = false
|
||||
|
||||
const executionId = options?.executionId
|
||||
const outputStatusCallback = options?.outputStatusCallback
|
||||
const outputThrottleMs = options?.outputThrottleMs ?? 100
|
||||
const terminalOutputLineLimit = options?.terminalOutputLineLimit ?? 500
|
||||
const terminalOutputCharacterLimit = options?.terminalOutputCharacterLimit
|
||||
const toolName = options?.toolName ?? context.tool?.name
|
||||
|
||||
// Throttled output emission: we keep raw stdout/stderr intact for final parsing,
|
||||
// and maintain a merged buffer for the UI (compressed on send).
|
||||
let mergedOutput = ""
|
||||
let lastEmitAt = 0
|
||||
let pendingEmitTimer: NodeJS.Timeout | undefined
|
||||
|
||||
const emitOutput = (force: boolean) => {
|
||||
if (!executionId || !outputStatusCallback) return
|
||||
const now = Date.now()
|
||||
const shouldEmit = force || now - lastEmitAt >= outputThrottleMs
|
||||
|
||||
if (!shouldEmit) {
|
||||
if (!pendingEmitTimer) {
|
||||
pendingEmitTimer = setTimeout(
|
||||
() => {
|
||||
pendingEmitTimer = undefined
|
||||
emitOutput(true)
|
||||
},
|
||||
Math.max(0, outputThrottleMs - (now - lastEmitAt)),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lastEmitAt = now
|
||||
const compressed = Terminal.compressTerminalOutput(
|
||||
mergedOutput,
|
||||
terminalOutputLineLimit,
|
||||
terminalOutputCharacterLimit,
|
||||
)
|
||||
|
||||
const payload: HookExecutionOutputStatusPayload = {
|
||||
executionId,
|
||||
hookId: hook.id,
|
||||
event: hook.event,
|
||||
toolName,
|
||||
status: "output",
|
||||
command: hook.command,
|
||||
cwd: context.project.directory,
|
||||
shell: hook.shell,
|
||||
pid: child?.pid,
|
||||
output: compressed,
|
||||
}
|
||||
|
||||
try {
|
||||
outputStatusCallback(payload)
|
||||
} catch {
|
||||
// Ignore callback errors
|
||||
}
|
||||
}
|
||||
|
||||
const finalize = (exitCode: number | null, error?: Error) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
|
||||
if (pendingEmitTimer) {
|
||||
clearTimeout(pendingEmitTimer)
|
||||
pendingEmitTimer = undefined
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// Try to parse modification from stdout
|
||||
const modification = parseModificationResponse(stdout, hook)
|
||||
|
||||
// Emit terminal state for UI streaming.
|
||||
if (executionId && outputStatusCallback) {
|
||||
let finalStatus: HookExecutionOutputStatusPayload["status"] = "exited"
|
||||
let blockMessage: string | undefined
|
||||
let errorMessage: string | undefined
|
||||
|
||||
if (error || exitCode === null) {
|
||||
finalStatus = "failed"
|
||||
errorMessage = error?.message || "Hook failed to execute"
|
||||
} else if (exitCode === HookExitCode.Block && isBlockingEvent(hook.event)) {
|
||||
finalStatus = "blocked"
|
||||
blockMessage = stderr.trim() || `Hook "${hook.id}" blocked execution`
|
||||
} else if (exitCode !== HookExitCode.Success) {
|
||||
finalStatus = "failed"
|
||||
}
|
||||
|
||||
const terminalPayload: HookExecutionOutputStatusPayload = {
|
||||
executionId,
|
||||
hookId: hook.id,
|
||||
event: hook.event,
|
||||
toolName,
|
||||
status: finalStatus,
|
||||
command: hook.command,
|
||||
cwd: context.project.directory,
|
||||
shell: hook.shell,
|
||||
pid: child?.pid,
|
||||
exitCode: exitCode ?? undefined,
|
||||
durationMs: duration,
|
||||
blockMessage,
|
||||
error: errorMessage,
|
||||
modified: !!modification,
|
||||
}
|
||||
|
||||
try {
|
||||
outputStatusCallback(terminalPayload)
|
||||
} catch {
|
||||
// Ignore callback errors
|
||||
}
|
||||
}
|
||||
|
||||
resolve({
|
||||
hook,
|
||||
exitCode,
|
||||
|
|
@ -213,6 +326,27 @@ export async function executeHook(
|
|||
windowsHide: true,
|
||||
})
|
||||
|
||||
// Emit started event after spawn.
|
||||
if (executionId && outputStatusCallback) {
|
||||
const startedPayload: HookExecutionOutputStatusPayload = {
|
||||
executionId,
|
||||
hookId: hook.id,
|
||||
event: hook.event,
|
||||
toolName,
|
||||
status: "started",
|
||||
command: hook.command,
|
||||
cwd: context.project.directory,
|
||||
shell: hook.shell,
|
||||
pid: child.pid,
|
||||
}
|
||||
|
||||
try {
|
||||
outputStatusCallback(startedPayload)
|
||||
} catch {
|
||||
// Ignore callback errors
|
||||
}
|
||||
}
|
||||
|
||||
// Write stdin
|
||||
if (child.stdin) {
|
||||
child.stdin.write(stdin)
|
||||
|
|
@ -222,30 +356,39 @@ export async function executeHook(
|
|||
// Capture stdout
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data: Buffer) => {
|
||||
stdout += data.toString()
|
||||
const chunk = data.toString()
|
||||
stdout += chunk
|
||||
mergedOutput += chunk
|
||||
emitOutput(false)
|
||||
})
|
||||
}
|
||||
|
||||
// Capture stderr
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString()
|
||||
const chunk = data.toString()
|
||||
stderr += chunk
|
||||
mergedOutput += chunk
|
||||
emitOutput(false)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle process exit
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeoutHandle)
|
||||
emitOutput(true)
|
||||
finalize(code)
|
||||
})
|
||||
|
||||
// Handle spawn errors
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timeoutHandle)
|
||||
emitOutput(true)
|
||||
finalize(null, err)
|
||||
})
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutHandle)
|
||||
emitOutput(true)
|
||||
finalize(null, err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import { filterMatchingHooks } from "./HookMatcher"
|
|||
import { executeHook, interpretResult, describeResult } from "./HookExecutor"
|
||||
import { updateHookConfig } from "./HookConfigWriter"
|
||||
|
||||
import { Terminal } from "../../integrations/terminal/Terminal"
|
||||
|
||||
/**
|
||||
* Default options for the HookManager.
|
||||
*/
|
||||
|
|
@ -138,6 +140,12 @@ export class HookManager implements IHookManager {
|
|||
const startTime = Date.now()
|
||||
const results: HookExecutionResult[] = []
|
||||
|
||||
// Stable per-hook-run executionId seed for UI (keep ordering/semantics intact).
|
||||
// If caller does not provide one, create a deterministic-enough unique id.
|
||||
const baseExecutionId =
|
||||
options.executionId ||
|
||||
`${options.context.session.taskId}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
// Ensure config is loaded
|
||||
if (!this.snapshot) {
|
||||
await this.loadHooksConfig()
|
||||
|
|
@ -166,7 +174,30 @@ export class HookManager implements IHookManager {
|
|||
this.log("debug", `Executing hook "${hook.id}" for ${event}`)
|
||||
|
||||
// Execute the hook
|
||||
const result = await executeHook(hook, options.context, options.conversationHistory)
|
||||
const executionId = `${baseExecutionId}:${event}:${hook.id}:${results.length}`
|
||||
// Notify caller (Task/ToolExecutionHooks) that a hook run is starting so it can
|
||||
// create a persisted `say: hook_execution` row keyed by this executionId.
|
||||
try {
|
||||
await options.hookExecutionCallback?.({
|
||||
phase: "started",
|
||||
executionId,
|
||||
hookId: hook.id,
|
||||
event,
|
||||
toolName: options.context.tool?.name,
|
||||
command: hook.command,
|
||||
cwd: options.context.project.directory,
|
||||
})
|
||||
} catch {
|
||||
// Ignore callback errors
|
||||
}
|
||||
const result = await executeHook(hook, options.context, options.conversationHistory, {
|
||||
executionId,
|
||||
toolName: options.context.tool?.name,
|
||||
outputStatusCallback: options.outputStatusCallback,
|
||||
outputThrottleMs: options.outputThrottleMs,
|
||||
terminalOutputLineLimit: options.terminalOutputLineLimit,
|
||||
terminalOutputCharacterLimit: options.terminalOutputCharacterLimit,
|
||||
})
|
||||
results.push(result)
|
||||
|
||||
// Record in history
|
||||
|
|
@ -178,6 +209,34 @@ export class HookManager implements IHookManager {
|
|||
// Interpret the result
|
||||
const interpretation = interpretResult(result)
|
||||
|
||||
// Notify caller of terminal state so it can update persisted chat row with a summary.
|
||||
try {
|
||||
const combinedOutput = `${result.stdout || ""}${result.stderr || ""}`
|
||||
const outputSummary = Terminal.compressTerminalOutput(
|
||||
combinedOutput,
|
||||
options.terminalOutputLineLimit ?? 500,
|
||||
options.terminalOutputCharacterLimit,
|
||||
)
|
||||
|
||||
await options.hookExecutionCallback?.({
|
||||
phase: interpretation.blocked ? "blocked" : interpretation.success ? "completed" : "failed",
|
||||
executionId,
|
||||
hookId: hook.id,
|
||||
event,
|
||||
toolName: options.context.tool?.name,
|
||||
command: hook.command,
|
||||
cwd: options.context.project.directory,
|
||||
outputSummary,
|
||||
exitCode: result.exitCode,
|
||||
durationMs: result.duration,
|
||||
blockMessage: interpretation.blockMessage,
|
||||
error: result.error?.message,
|
||||
modified: !!result.modification,
|
||||
})
|
||||
} catch {
|
||||
// Ignore callback errors
|
||||
}
|
||||
|
||||
// Check for blocking
|
||||
if (interpretation.blocked) {
|
||||
blocked = true
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ import type {
|
|||
HookProjectContext,
|
||||
HookToolContext,
|
||||
HooksExecutionResult,
|
||||
ExecuteHooksOptions,
|
||||
} from "./types"
|
||||
|
||||
import type { HookExecutionOutputStatusPayload } from "@roo-code/types"
|
||||
|
||||
/**
|
||||
* Tool execution context for hooks.
|
||||
*/
|
||||
|
|
@ -70,11 +71,18 @@ export type HookStatusCallback = (status: {
|
|||
modified?: boolean
|
||||
}) => void
|
||||
|
||||
/**
|
||||
* Callback for streaming terminal-style hook execution output status updates to the webview.
|
||||
*/
|
||||
export type HookOutputStatusCallback = (payload: HookExecutionOutputStatusPayload) => void
|
||||
|
||||
/**
|
||||
* Callback for emitting messages to chat history.
|
||||
*/
|
||||
export type SayCallback = (type: string, text?: string) => Promise<void>
|
||||
|
||||
export type UpdateSayCallback = (messageTs: number, type: string, text?: string) => Promise<void>
|
||||
|
||||
/**
|
||||
* Callback to check if hooks are globally enabled.
|
||||
*/
|
||||
|
|
@ -88,18 +96,24 @@ export type HooksEnabledGetter = () => boolean
|
|||
export class ToolExecutionHooks {
|
||||
private hookManager: IHookManager | null
|
||||
private statusCallback?: HookStatusCallback
|
||||
private outputStatusCallback?: HookOutputStatusCallback
|
||||
private sayCallback?: SayCallback
|
||||
private updateSayCallback?: UpdateSayCallback
|
||||
private hooksEnabledGetter?: HooksEnabledGetter
|
||||
|
||||
constructor(
|
||||
hookManager: IHookManager | null,
|
||||
statusCallback?: HookStatusCallback,
|
||||
outputStatusCallback?: HookOutputStatusCallback,
|
||||
sayCallback?: SayCallback,
|
||||
updateSayCallback?: UpdateSayCallback,
|
||||
hooksEnabledGetter?: HooksEnabledGetter,
|
||||
) {
|
||||
this.hookManager = hookManager
|
||||
this.statusCallback = statusCallback
|
||||
this.outputStatusCallback = outputStatusCallback
|
||||
this.sayCallback = sayCallback
|
||||
this.updateSayCallback = updateSayCallback
|
||||
this.hooksEnabledGetter = hooksEnabledGetter
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +131,13 @@ export class ToolExecutionHooks {
|
|||
this.statusCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the output status callback.
|
||||
*/
|
||||
setOutputStatusCallback(callback: HookOutputStatusCallback | undefined): void {
|
||||
this.outputStatusCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the say callback.
|
||||
*/
|
||||
|
|
@ -124,6 +145,10 @@ export class ToolExecutionHooks {
|
|||
this.sayCallback = callback
|
||||
}
|
||||
|
||||
setUpdateSayCallback(callback: UpdateSayCallback | undefined): void {
|
||||
this.updateSayCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the hooks enabled getter.
|
||||
*/
|
||||
|
|
@ -171,9 +196,14 @@ export class ToolExecutionHooks {
|
|||
})
|
||||
|
||||
try {
|
||||
const result = await this.hookManager.executeHooks("PreToolUse", { context: hookContext })
|
||||
const result = await this.hookManager.executeHooks("PreToolUse", {
|
||||
context: hookContext,
|
||||
executionId: `${context.session.taskId}:${Date.now()}`,
|
||||
outputStatusCallback: this.outputStatusCallback,
|
||||
hookExecutionCallback: this.buildHookExecutionCallback(),
|
||||
})
|
||||
|
||||
await this.emitHookTriggeredMessages(result)
|
||||
// Legacy: hook_triggered rows are deprecated by hook_execution and would duplicate.
|
||||
|
||||
if (result.blocked) {
|
||||
// Hook blocked the execution
|
||||
|
|
@ -262,9 +292,14 @@ export class ToolExecutionHooks {
|
|||
})
|
||||
|
||||
try {
|
||||
const result = await this.hookManager.executeHooks("PostToolUse", { context: hookContext })
|
||||
const result = await this.hookManager.executeHooks("PostToolUse", {
|
||||
context: hookContext,
|
||||
executionId: `${context.session.taskId}:${Date.now()}`,
|
||||
outputStatusCallback: this.outputStatusCallback,
|
||||
hookExecutionCallback: this.buildHookExecutionCallback(),
|
||||
})
|
||||
|
||||
await this.emitHookTriggeredMessages(result)
|
||||
// Legacy: hook_triggered rows are deprecated by hook_execution and would duplicate.
|
||||
|
||||
this.emitStatus({
|
||||
status: "completed",
|
||||
|
|
@ -322,9 +357,14 @@ export class ToolExecutionHooks {
|
|||
})
|
||||
|
||||
try {
|
||||
const result = await this.hookManager.executeHooks("PostToolUseFailure", { context: hookContext })
|
||||
const result = await this.hookManager.executeHooks("PostToolUseFailure", {
|
||||
context: hookContext,
|
||||
executionId: `${context.session.taskId}:${Date.now()}`,
|
||||
outputStatusCallback: this.outputStatusCallback,
|
||||
hookExecutionCallback: this.buildHookExecutionCallback(),
|
||||
})
|
||||
|
||||
await this.emitHookTriggeredMessages(result)
|
||||
// Legacy: hook_triggered rows are deprecated by hook_execution and would duplicate.
|
||||
|
||||
this.emitStatus({
|
||||
status: "completed",
|
||||
|
|
@ -383,9 +423,14 @@ export class ToolExecutionHooks {
|
|||
})
|
||||
|
||||
try {
|
||||
const result = await this.hookManager.executeHooks("PermissionRequest", { context: hookContext })
|
||||
const result = await this.hookManager.executeHooks("PermissionRequest", {
|
||||
context: hookContext,
|
||||
executionId: `${context.session.taskId}:${Date.now()}`,
|
||||
outputStatusCallback: this.outputStatusCallback,
|
||||
hookExecutionCallback: this.buildHookExecutionCallback(),
|
||||
})
|
||||
|
||||
await this.emitHookTriggeredMessages(result)
|
||||
// Legacy: hook_triggered rows are deprecated by hook_execution and would duplicate.
|
||||
|
||||
if (result.blocked) {
|
||||
// Hook blocked - do not show approval dialog, deny the tool
|
||||
|
|
@ -501,23 +546,67 @@ export class ToolExecutionHooks {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit hook triggered messages for successful hook executions.
|
||||
*/
|
||||
private async emitHookTriggeredMessages(result: HooksExecutionResult): Promise<void> {
|
||||
if (!this.sayCallback) {
|
||||
return
|
||||
// Persisted hook_execution rows need to be stable across all hook runs for a task.
|
||||
// Keep this mapping on the instance so multiple executeHooks() calls don't lose update ability.
|
||||
private hookExecutionMessageTsByExecutionId = new Map<string, number>()
|
||||
private hookExecutionCallbackInstance?: NonNullable<import("./types").ExecuteHooksOptions["hookExecutionCallback"]>
|
||||
|
||||
private buildHookExecutionCallback(): NonNullable<import("./types").ExecuteHooksOptions["hookExecutionCallback"]> {
|
||||
if (this.hookExecutionCallbackInstance) {
|
||||
return this.hookExecutionCallbackInstance
|
||||
}
|
||||
|
||||
for (const hookResult of result.results) {
|
||||
if (!hookResult.error && hookResult.exitCode === 0) {
|
||||
try {
|
||||
await this.sayCallback("hook_triggered", hookResult.hook.id)
|
||||
} catch {
|
||||
// Ignore callback errors
|
||||
this.hookExecutionCallbackInstance = async (evt) => {
|
||||
if (!this.sayCallback) return
|
||||
|
||||
if (evt.phase === "started") {
|
||||
// Create one persisted row per hook run.
|
||||
// We include an embedded messageTs so we can update the exact row later.
|
||||
const messageTs = Date.now()
|
||||
this.hookExecutionMessageTsByExecutionId.set(evt.executionId, messageTs)
|
||||
|
||||
const payload = {
|
||||
executionId: evt.executionId,
|
||||
hookId: evt.hookId,
|
||||
event: evt.event,
|
||||
toolName: evt.toolName,
|
||||
command: evt.command,
|
||||
// Required for stable updates.
|
||||
messageTs,
|
||||
}
|
||||
|
||||
await this.sayCallback("hook_execution", JSON.stringify(payload))
|
||||
return
|
||||
}
|
||||
|
||||
// Terminal states: update persisted row with a compressed output summary.
|
||||
const messageTs = this.hookExecutionMessageTsByExecutionId.get(evt.executionId)
|
||||
if (!messageTs || !this.updateSayCallback) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
executionId: evt.executionId,
|
||||
hookId: evt.hookId,
|
||||
event: evt.event,
|
||||
toolName: evt.toolName,
|
||||
command: evt.command,
|
||||
messageTs,
|
||||
result: {
|
||||
phase: evt.phase,
|
||||
exitCode: evt.exitCode,
|
||||
durationMs: evt.durationMs,
|
||||
blockMessage: evt.blockMessage,
|
||||
error: evt.error,
|
||||
modified: evt.modified,
|
||||
outputSummary: evt.outputSummary,
|
||||
},
|
||||
}
|
||||
|
||||
await this.updateSayCallback(messageTs, "hook_execution", JSON.stringify(payload))
|
||||
}
|
||||
|
||||
return this.hookExecutionCallbackInstance
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -527,8 +616,17 @@ export class ToolExecutionHooks {
|
|||
export function createToolExecutionHooks(
|
||||
hookManager: IHookManager | null,
|
||||
statusCallback?: HookStatusCallback,
|
||||
outputStatusCallback?: HookOutputStatusCallback,
|
||||
sayCallback?: SayCallback,
|
||||
updateSayCallback?: UpdateSayCallback,
|
||||
hooksEnabledGetter?: HooksEnabledGetter,
|
||||
): ToolExecutionHooks {
|
||||
return new ToolExecutionHooks(hookManager, statusCallback, sayCallback, hooksEnabledGetter)
|
||||
return new ToolExecutionHooks(
|
||||
hookManager,
|
||||
statusCallback,
|
||||
outputStatusCallback,
|
||||
sayCallback,
|
||||
updateSayCallback,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import { HookManager, createHookManager } from "../HookManager"
|
||||
import * as HookConfigLoader from "../HookConfigLoader"
|
||||
import * as HookExecutor from "../HookExecutor"
|
||||
import { Terminal } from "../../../integrations/terminal/Terminal"
|
||||
import type { HooksConfigSnapshot, ResolvedHook, HookEventType, HookContext } from "../types"
|
||||
|
||||
// Mock dependencies
|
||||
|
|
@ -163,6 +164,74 @@ describe("HookManager", () => {
|
|||
tool: { name: "Write", input: { filePath: "/test.ts", content: "test" } },
|
||||
})
|
||||
|
||||
it("should call hookExecutionCallback on start and terminal state", async () => {
|
||||
const compressSpy = vi.spyOn(Terminal, "compressTerminalOutput").mockImplementation((s: string) => s)
|
||||
|
||||
const hook1 = createMockHook("hook1")
|
||||
const snapshot = createMockSnapshot([hook1])
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
mockGetHooksForEvent.mockReturnValue([hook1])
|
||||
mockExecuteHook.mockResolvedValue({
|
||||
hook: hook1,
|
||||
exitCode: 0,
|
||||
stdout: "out",
|
||||
stderr: "err",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
})
|
||||
mockInterpretResult.mockReturnValue({
|
||||
success: true,
|
||||
blocked: false,
|
||||
blockMessage: undefined,
|
||||
shouldContinue: true,
|
||||
})
|
||||
|
||||
const hookExecutionCallback = vi.fn()
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
await manager.executeHooks("PreToolUse", {
|
||||
context: createMockContext(),
|
||||
executionId: "base",
|
||||
hookExecutionCallback,
|
||||
})
|
||||
|
||||
expect(hookExecutionCallback).toHaveBeenCalledTimes(2)
|
||||
const [startedEvt] = hookExecutionCallback.mock.calls[0]
|
||||
const [terminalEvt] = hookExecutionCallback.mock.calls[1]
|
||||
|
||||
expect(startedEvt).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: "started",
|
||||
hookId: "hook1",
|
||||
event: "PreToolUse",
|
||||
command: "echo hook1",
|
||||
cwd: "/project",
|
||||
executionId: expect.stringContaining("base"),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(terminalEvt).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: "completed",
|
||||
hookId: "hook1",
|
||||
event: "PreToolUse",
|
||||
executionId: startedEvt.executionId,
|
||||
outputSummary: "outerr",
|
||||
exitCode: 0,
|
||||
durationMs: 100,
|
||||
}),
|
||||
)
|
||||
|
||||
compressSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should execute hooks sequentially", async () => {
|
||||
const hook1 = createMockHook("hook1")
|
||||
const hook2 = createMockHook("hook2")
|
||||
|
|
|
|||
|
|
@ -47,7 +47,14 @@ describe("ToolExecutionHooks", () => {
|
|||
const mockManager = createMockHookManager()
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => false
|
||||
|
||||
const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, hooksEnabledGetter)
|
||||
const hooks = new ToolExecutionHooks(
|
||||
mockManager,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
const result = await hooks.executePreToolUse(createMockContext())
|
||||
|
||||
|
|
@ -61,7 +68,14 @@ describe("ToolExecutionHooks", () => {
|
|||
const mockManager = createMockHookManager()
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => true
|
||||
|
||||
const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, hooksEnabledGetter)
|
||||
const hooks = new ToolExecutionHooks(
|
||||
mockManager,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
await hooks.executePreToolUse(createMockContext())
|
||||
|
||||
|
|
@ -72,7 +86,7 @@ describe("ToolExecutionHooks", () => {
|
|||
const mockManager = createMockHookManager()
|
||||
|
||||
// No hooksEnabledGetter provided
|
||||
const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, undefined)
|
||||
const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, undefined, undefined, undefined)
|
||||
|
||||
await hooks.executePreToolUse(createMockContext())
|
||||
|
||||
|
|
@ -83,7 +97,14 @@ describe("ToolExecutionHooks", () => {
|
|||
const mockManager = createMockHookManager()
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => false
|
||||
|
||||
const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, hooksEnabledGetter)
|
||||
const hooks = new ToolExecutionHooks(
|
||||
mockManager,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
const result = await hooks.executePostToolUse(createMockContext(), "output", 100)
|
||||
|
||||
|
|
@ -96,7 +117,14 @@ describe("ToolExecutionHooks", () => {
|
|||
const mockManager = createMockHookManager()
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => false
|
||||
|
||||
const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, hooksEnabledGetter)
|
||||
const hooks = new ToolExecutionHooks(
|
||||
mockManager,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
const result = await hooks.executePostToolUseFailure(createMockContext(), "error", "error message")
|
||||
|
||||
|
|
@ -109,7 +137,14 @@ describe("ToolExecutionHooks", () => {
|
|||
const mockManager = createMockHookManager()
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => false
|
||||
|
||||
const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, hooksEnabledGetter)
|
||||
const hooks = new ToolExecutionHooks(
|
||||
mockManager,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
const result = await hooks.executePermissionRequest(createMockContext())
|
||||
|
||||
|
|
@ -123,7 +158,14 @@ describe("ToolExecutionHooks", () => {
|
|||
const mockManager = createMockHookManager()
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => false
|
||||
|
||||
const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, hooksEnabledGetter)
|
||||
const hooks = new ToolExecutionHooks(
|
||||
mockManager,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
expect(hooks.hasHooks()).toBe(false)
|
||||
})
|
||||
|
|
@ -132,7 +174,14 @@ describe("ToolExecutionHooks", () => {
|
|||
const mockManager = createMockHookManager()
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => true
|
||||
|
||||
const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, hooksEnabledGetter)
|
||||
const hooks = new ToolExecutionHooks(
|
||||
mockManager,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
expect(hooks.hasHooks()).toBe(true)
|
||||
})
|
||||
|
|
@ -143,7 +192,14 @@ describe("ToolExecutionHooks", () => {
|
|||
const mockManager = createMockHookManager()
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => false
|
||||
|
||||
const hooks = createToolExecutionHooks(mockManager, undefined, undefined, hooksEnabledGetter)
|
||||
const hooks = createToolExecutionHooks(
|
||||
mockManager,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
// Verify hooks don't execute when disabled
|
||||
await hooks.executePreToolUse(createMockContext())
|
||||
|
|
@ -153,7 +209,7 @@ describe("ToolExecutionHooks", () => {
|
|||
it("should work without hooksEnabledGetter for backwards compatibility", async () => {
|
||||
const mockManager = createMockHookManager()
|
||||
|
||||
const hooks = createToolExecutionHooks(mockManager, undefined, undefined)
|
||||
const hooks = createToolExecutionHooks(mockManager, undefined, undefined, undefined)
|
||||
|
||||
await hooks.executePreToolUse(createMockContext())
|
||||
expect(mockManager.executeHooks).toHaveBeenCalled()
|
||||
|
|
@ -164,7 +220,7 @@ describe("ToolExecutionHooks", () => {
|
|||
it("should allow updating the getter after construction", async () => {
|
||||
const mockManager = createMockHookManager()
|
||||
|
||||
const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, () => true)
|
||||
const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, undefined, undefined, () => true)
|
||||
|
||||
// First call with enabled
|
||||
await hooks.executePreToolUse(createMockContext())
|
||||
|
|
@ -181,7 +237,7 @@ describe("ToolExecutionHooks", () => {
|
|||
|
||||
describe("No hook manager", () => {
|
||||
it("should return default results when hookManager is null", async () => {
|
||||
const hooks = new ToolExecutionHooks(null, undefined, undefined, () => true)
|
||||
const hooks = new ToolExecutionHooks(null, undefined, undefined, undefined, undefined, () => true)
|
||||
|
||||
const result = await hooks.executePreToolUse(createMockContext())
|
||||
|
||||
|
|
@ -190,7 +246,7 @@ describe("ToolExecutionHooks", () => {
|
|||
})
|
||||
|
||||
it("hasHooks should return false when hookManager is null", () => {
|
||||
const hooks = new ToolExecutionHooks(null, undefined, undefined, () => true)
|
||||
const hooks = new ToolExecutionHooks(null, undefined, undefined, undefined, undefined, () => true)
|
||||
|
||||
expect(hooks.hasHooks()).toBe(false)
|
||||
})
|
||||
|
|
@ -202,7 +258,14 @@ describe("ToolExecutionHooks", () => {
|
|||
const statusCallback: HookStatusCallback = vi.fn()
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => false
|
||||
|
||||
const hooks = new ToolExecutionHooks(mockManager, statusCallback, undefined, hooksEnabledGetter)
|
||||
const hooks = new ToolExecutionHooks(
|
||||
mockManager,
|
||||
statusCallback,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
await hooks.executePreToolUse(createMockContext())
|
||||
|
||||
|
|
@ -214,11 +277,219 @@ describe("ToolExecutionHooks", () => {
|
|||
const statusCallback: HookStatusCallback = vi.fn()
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => true
|
||||
|
||||
const hooks = new ToolExecutionHooks(mockManager, statusCallback, undefined, hooksEnabledGetter)
|
||||
const hooks = new ToolExecutionHooks(
|
||||
mockManager,
|
||||
statusCallback,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
await hooks.executePreToolUse(createMockContext())
|
||||
|
||||
expect(statusCallback).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Persisted hook_execution rows", () => {
|
||||
it("should emit hook_execution on start and update it on completion", async () => {
|
||||
const mockManager = createMockHookManager()
|
||||
const sayCallback: SayCallback = vi.fn().mockResolvedValue(undefined)
|
||||
const updateSayCallback = vi.fn().mockResolvedValue(undefined)
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => true
|
||||
|
||||
const hooks = new ToolExecutionHooks(
|
||||
mockManager,
|
||||
undefined,
|
||||
undefined,
|
||||
sayCallback,
|
||||
updateSayCallback,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
await hooks.executePreToolUse(createMockContext())
|
||||
|
||||
const executeArgs = (mockManager.executeHooks as any).mock.calls[0][1]
|
||||
expect(executeArgs.hookExecutionCallback).toEqual(expect.any(Function))
|
||||
|
||||
// Deterministic messageTs for the persisted row.
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValueOnce(123456)
|
||||
|
||||
await executeArgs.hookExecutionCallback({
|
||||
phase: "started",
|
||||
executionId: "exec_1",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
toolName: "Write",
|
||||
command: "echo hi",
|
||||
cwd: "/test/project",
|
||||
})
|
||||
|
||||
expect(sayCallback).toHaveBeenCalledTimes(1)
|
||||
expect(sayCallback).toHaveBeenCalledWith("hook_execution", expect.any(String))
|
||||
const startPayload = JSON.parse((sayCallback as any).mock.calls[0][1])
|
||||
expect(startPayload).toEqual(
|
||||
expect.objectContaining({
|
||||
executionId: "exec_1",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
toolName: "Write",
|
||||
command: "echo hi",
|
||||
messageTs: 123456,
|
||||
}),
|
||||
)
|
||||
|
||||
await executeArgs.hookExecutionCallback({
|
||||
phase: "completed",
|
||||
executionId: "exec_1",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
toolName: "Write",
|
||||
command: "echo hi",
|
||||
cwd: "/test/project",
|
||||
outputSummary: "ok",
|
||||
exitCode: 0,
|
||||
durationMs: 10,
|
||||
modified: false,
|
||||
})
|
||||
|
||||
expect(updateSayCallback).toHaveBeenCalledTimes(1)
|
||||
expect(updateSayCallback).toHaveBeenCalledWith(123456, "hook_execution", expect.any(String))
|
||||
const updatePayload = JSON.parse((updateSayCallback as any).mock.calls[0][2])
|
||||
expect(updatePayload).toEqual(
|
||||
expect.objectContaining({
|
||||
executionId: "exec_1",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
toolName: "Write",
|
||||
command: "echo hi",
|
||||
messageTs: 123456,
|
||||
result: expect.objectContaining({
|
||||
phase: "completed",
|
||||
exitCode: 0,
|
||||
durationMs: 10,
|
||||
modified: false,
|
||||
outputSummary: "ok",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should update persisted hook_execution row with blocked terminal state", async () => {
|
||||
const mockManager = createMockHookManager()
|
||||
const sayCallback: SayCallback = vi.fn().mockResolvedValue(undefined)
|
||||
const updateSayCallback = vi.fn().mockResolvedValue(undefined)
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => true
|
||||
|
||||
const hooks = new ToolExecutionHooks(
|
||||
mockManager,
|
||||
undefined,
|
||||
undefined,
|
||||
sayCallback,
|
||||
updateSayCallback,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
await hooks.executePreToolUse(createMockContext())
|
||||
const executeArgs = (mockManager.executeHooks as any).mock.calls[0][1]
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValueOnce(111)
|
||||
await executeArgs.hookExecutionCallback({
|
||||
phase: "started",
|
||||
executionId: "exec_2",
|
||||
hookId: "hook_2",
|
||||
event: "PreToolUse",
|
||||
toolName: "Write",
|
||||
command: "echo block",
|
||||
cwd: "/test/project",
|
||||
})
|
||||
|
||||
await executeArgs.hookExecutionCallback({
|
||||
phase: "blocked",
|
||||
executionId: "exec_2",
|
||||
hookId: "hook_2",
|
||||
event: "PreToolUse",
|
||||
toolName: "Write",
|
||||
command: "echo block",
|
||||
cwd: "/test/project",
|
||||
outputSummary: "blocked-output",
|
||||
exitCode: 2,
|
||||
durationMs: 12,
|
||||
blockMessage: "Policy violation",
|
||||
modified: true,
|
||||
})
|
||||
|
||||
expect(updateSayCallback).toHaveBeenCalledTimes(1)
|
||||
const updatePayload = JSON.parse((updateSayCallback as any).mock.calls[0][2])
|
||||
expect(updatePayload.result).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: "blocked",
|
||||
exitCode: 2,
|
||||
durationMs: 12,
|
||||
blockMessage: "Policy violation",
|
||||
modified: true,
|
||||
outputSummary: "blocked-output",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should update persisted hook_execution row with failed terminal state", async () => {
|
||||
const mockManager = createMockHookManager()
|
||||
const sayCallback: SayCallback = vi.fn().mockResolvedValue(undefined)
|
||||
const updateSayCallback = vi.fn().mockResolvedValue(undefined)
|
||||
const hooksEnabledGetter: HooksEnabledGetter = () => true
|
||||
|
||||
const hooks = new ToolExecutionHooks(
|
||||
mockManager,
|
||||
undefined,
|
||||
undefined,
|
||||
sayCallback,
|
||||
updateSayCallback,
|
||||
hooksEnabledGetter,
|
||||
)
|
||||
|
||||
await hooks.executePreToolUse(createMockContext())
|
||||
const executeArgs = (mockManager.executeHooks as any).mock.calls[0][1]
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValueOnce(222)
|
||||
await executeArgs.hookExecutionCallback({
|
||||
phase: "started",
|
||||
executionId: "exec_3",
|
||||
hookId: "hook_3",
|
||||
event: "PreToolUse",
|
||||
toolName: "Write",
|
||||
command: "echo fail",
|
||||
cwd: "/test/project",
|
||||
})
|
||||
|
||||
await executeArgs.hookExecutionCallback({
|
||||
phase: "failed",
|
||||
executionId: "exec_3",
|
||||
hookId: "hook_3",
|
||||
event: "PreToolUse",
|
||||
toolName: "Write",
|
||||
command: "echo fail",
|
||||
cwd: "/test/project",
|
||||
outputSummary: "failed-output",
|
||||
exitCode: 1,
|
||||
durationMs: 34,
|
||||
error: "Hook error",
|
||||
})
|
||||
|
||||
expect(updateSayCallback).toHaveBeenCalledTimes(1)
|
||||
const updatePayload = JSON.parse((updateSayCallback as any).mock.calls[0][2])
|
||||
expect(updatePayload.result).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: "failed",
|
||||
exitCode: 1,
|
||||
durationMs: 34,
|
||||
error: "Hook error",
|
||||
outputSummary: "failed-output",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { z } from "zod"
|
||||
import type { HookExecutionOutputStatusPayload } from "@roo-code/types"
|
||||
|
||||
// ============================================================================
|
||||
// Hook Events
|
||||
|
|
@ -404,6 +405,40 @@ export interface ExecuteHooksOptions {
|
|||
|
||||
/** Conversation history (will be included only for hooks with includeConversationHistory: true) */
|
||||
conversationHistory?: ConversationHistoryEntry[]
|
||||
|
||||
/** Optional stable base execution id for this hook batch (if omitted, manager generates one). */
|
||||
executionId?: string
|
||||
|
||||
/** Optional streaming output status callback (used by chat terminal UI). */
|
||||
outputStatusCallback?: (payload: HookExecutionOutputStatusPayload) => void
|
||||
|
||||
/** Optional output throttling interval in ms (default handled by executor). */
|
||||
outputThrottleMs?: number
|
||||
|
||||
/** Optional output compression / truncation settings (default handled by executor). */
|
||||
terminalOutputLineLimit?: number
|
||||
terminalOutputCharacterLimit?: number
|
||||
|
||||
/**
|
||||
* Optional lifecycle callback for persisted hook_execution chat rows.
|
||||
* Called for each hook run with its unique executionId.
|
||||
*/
|
||||
hookExecutionCallback?: (event: {
|
||||
phase: "started" | "completed" | "failed" | "blocked"
|
||||
executionId: string
|
||||
hookId: string
|
||||
event: HookEventType
|
||||
toolName?: string
|
||||
command: string
|
||||
cwd: string
|
||||
/** Compressed/trimmed summary output for transcript persistence (terminal states only). */
|
||||
outputSummary?: string
|
||||
exitCode?: number | null
|
||||
durationMs?: number
|
||||
blockMessage?: string
|
||||
error?: string
|
||||
modified?: boolean
|
||||
}) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { BatchDiffApproval } from "./BatchDiffApproval"
|
|||
import { ProgressIndicator } from "./ProgressIndicator"
|
||||
import { Markdown } from "./Markdown"
|
||||
import { CommandExecution } from "./CommandExecution"
|
||||
import { HookExecution } from "./HookExecution"
|
||||
import { CommandExecutionError } from "./CommandExecutionError"
|
||||
import { AutoApprovedRequestLimitWarning } from "./AutoApprovedRequestLimitWarning"
|
||||
import { InProgressRow, CondensationResultRow, CondensationErrorRow, TruncationResultRow } from "./context-management"
|
||||
|
|
@ -1365,6 +1366,8 @@ export const ChatRowContent = ({
|
|||
checkpoint={message.checkpoint}
|
||||
/>
|
||||
)
|
||||
case "hook_execution":
|
||||
return <HookExecution message={message} />
|
||||
case "hook_triggered":
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
204
webview-ui/src/components/chat/HookExecution.tsx
Normal file
204
webview-ui/src/components/chat/HookExecution.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { useCallback, useState, useMemo, memo } from "react"
|
||||
import { useEvent } from "react-use"
|
||||
import { t } from "i18next"
|
||||
import { ChevronDown, FishingHook } from "lucide-react"
|
||||
|
||||
import {
|
||||
type ExtensionMessage,
|
||||
hookExecutionOutputStatusSchema,
|
||||
type HookExecutionOutputStatusPayload,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { safeJsonParse } from "@roo/core"
|
||||
|
||||
import { cn } from "@src/lib/utils"
|
||||
import { Button, StandardTooltip } from "@src/components/ui"
|
||||
import CodeBlock from "@src/components/common/CodeBlock"
|
||||
|
||||
interface HookExecutionProps {
|
||||
message: {
|
||||
text?: string
|
||||
}
|
||||
}
|
||||
|
||||
type HookExecutionInitialPayload = {
|
||||
executionId?: string
|
||||
hookId?: string
|
||||
event?: string
|
||||
toolName?: string
|
||||
command?: string
|
||||
}
|
||||
|
||||
export const HookExecution = ({ message }: HookExecutionProps) => {
|
||||
const initialData = useMemo(
|
||||
() => safeJsonParse<HookExecutionInitialPayload>(message.text || "{}", {} as HookExecutionInitialPayload),
|
||||
[message.text],
|
||||
)
|
||||
const { executionId, hookId, event, toolName, command } = initialData || {}
|
||||
|
||||
// Initialize status from initialData if available (e.g. if reloaded from history and it has a result)
|
||||
// For now, we assume initialData mainly contains static info and maybe a final result summary if persisted.
|
||||
// If the hook is currently running, we'll get updates.
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [streamingOutput, setStreamingOutput] = useState("")
|
||||
const [status, setStatus] = useState<HookExecutionOutputStatusPayload | null>(null)
|
||||
|
||||
// Combine streaming output with any potential initial output (if we decide to persist it later)
|
||||
// For now, per instructions, streaming output is ephemeral.
|
||||
const output = streamingOutput
|
||||
|
||||
const onMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
const msg: ExtensionMessage = event.data
|
||||
|
||||
if (msg.type === "hookExecutionOutputStatus") {
|
||||
// We use the schema to validate/parse the payload
|
||||
// The payload is in msg.text as a JSON string for this message type, per schema in vscode-extension-host.ts
|
||||
// Wait, looking at vscode-extension-host.ts:
|
||||
// export interface ExtensionMessage { ... hookExecutionOutputStatus ... text?: string ... }
|
||||
// And hookExecutionOutputStatusSchema describes the parsed object.
|
||||
// CommandExecution parses msg.text. Let's do the same.
|
||||
|
||||
const result = hookExecutionOutputStatusSchema.safeParse(safeJsonParse(msg.text, {}))
|
||||
|
||||
if (result.success) {
|
||||
const data = result.data
|
||||
|
||||
if (data.executionId !== executionId) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (data.status) {
|
||||
case "started":
|
||||
setStatus(data)
|
||||
break
|
||||
case "output":
|
||||
setStreamingOutput(data.output || "")
|
||||
break
|
||||
case "blocked":
|
||||
case "failed":
|
||||
setStatus(data)
|
||||
setIsExpanded(true) // Auto-expand on failure/block
|
||||
break
|
||||
case "exited":
|
||||
setStatus(data)
|
||||
break
|
||||
default:
|
||||
setStatus(data)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[executionId],
|
||||
)
|
||||
|
||||
useEvent("message", onMessage)
|
||||
|
||||
// Determine status color and icon
|
||||
const getStatusIndicator = () => {
|
||||
if (!status) return null // Or a default "pending" state if needed
|
||||
|
||||
if (status.status === "started") {
|
||||
return <span className="codicon codicon-loading codicon-modifier-spin" />
|
||||
}
|
||||
|
||||
if (status.status === "exited") {
|
||||
const isSuccess = status.exitCode === 0
|
||||
return (
|
||||
<StandardTooltip content={t("chat.commandExecution.exitStatus", { exitStatus: status.exitCode })}>
|
||||
<div className={cn("rounded-full size-2", isSuccess ? "bg-green-600" : "bg-red-600")} />
|
||||
</StandardTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
if (status.status === "failed") {
|
||||
return (
|
||||
<StandardTooltip content={status.error || "Failed"}>
|
||||
<div className="rounded-full size-2 bg-red-600" />
|
||||
</StandardTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
if (status.status === "blocked") {
|
||||
return (
|
||||
<StandardTooltip content={status.blockMessage || "Blocked"}>
|
||||
<div className="rounded-full size-2 bg-amber-500" />
|
||||
</StandardTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="hook-execution">
|
||||
<div className="flex flex-row items-center justify-between gap-2 mb-1">
|
||||
<div className="flex flex-row items-center gap-2 overflow-hidden">
|
||||
<FishingHook className="size-4 shrink-0" aria-label="Hook icon" />
|
||||
<span className="font-bold truncate" title={hookId}>
|
||||
{hookId}
|
||||
</span>
|
||||
<span className="text-xs text-vscode-descriptionForeground truncate">
|
||||
({event}
|
||||
{toolName ? `:${toolName}` : ""})
|
||||
</span>
|
||||
{getStatusIndicator()}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between gap-2 px-1">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{status?.modified && <VSCodeBadge className="text-xs h-5">Modified</VSCodeBadge>}
|
||||
<Button
|
||||
data-testid="hook-execution-toggle"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<ChevronDown
|
||||
className={cn("size-4 transition-transform duration-300", isExpanded && "rotate-180")}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-vscode-editor-background border border-vscode-border rounded-xs ml-6 mt-2">
|
||||
<div className="p-2">
|
||||
<CodeBlock source={command} language="shell" />
|
||||
<OutputContainer isExpanded={isExpanded} output={output} />
|
||||
{status?.blockMessage && isExpanded && (
|
||||
<div className="mt-2 text-amber-500 font-mono text-xs">{status.blockMessage}</div>
|
||||
)}
|
||||
{status?.error && isExpanded && (
|
||||
<div className="mt-2 text-red-500 font-mono text-xs">{status.error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
HookExecution.displayName = "HookExecution"
|
||||
|
||||
const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean; output: string }) => (
|
||||
<div
|
||||
className={cn("overflow-hidden", {
|
||||
"max-h-0": !isExpanded,
|
||||
"max-h-[100%] mt-1 pt-1 border-t border-border/25": isExpanded,
|
||||
})}>
|
||||
{output.length > 0 && <CodeBlock source={output} language="log" initialWordWrap={true} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
const OutputContainer = memo(OutputContainerInternal)
|
||||
|
||||
// Helper component for the Badge to avoid importing from vscode toolkit directly if not wrapped
|
||||
const VSCodeBadge = ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"bg-vscode-badge-background text-vscode-badge-foreground px-1.5 py-0.5 rounded-xs font-mono uppercase",
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import React from "react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { render, screen } from "@/utils/test-utils"
|
||||
|
||||
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
|
||||
|
||||
import { ChatRowContent } from "../ChatRow"
|
||||
|
||||
vi.mock("../HookExecution", () => ({
|
||||
HookExecution: ({ message }: any) => <div data-testid="hook-execution-mock">{message?.text}</div>,
|
||||
}))
|
||||
|
||||
describe("ChatRowContent - hook_execution", () => {
|
||||
it("renders HookExecution for say: hook_execution", () => {
|
||||
const queryClient = new QueryClient()
|
||||
const message: any = {
|
||||
type: "say",
|
||||
say: "hook_execution",
|
||||
ts: Date.now(),
|
||||
text: JSON.stringify({
|
||||
executionId: "exec_1",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
toolName: "Write",
|
||||
command: "echo hi",
|
||||
messageTs: 123,
|
||||
}),
|
||||
partial: false,
|
||||
}
|
||||
|
||||
render(
|
||||
<ExtensionStateContextProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChatRowContent
|
||||
message={message}
|
||||
isExpanded={false}
|
||||
isLast={false}
|
||||
isStreaming={false}
|
||||
onToggleExpand={() => {}}
|
||||
onSuggestionClick={() => {}}
|
||||
onBatchFileResponse={() => {}}
|
||||
onFollowUpUnmount={() => {}}
|
||||
isFollowUpAnswered={false}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("hook-execution-mock")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
126
webview-ui/src/components/chat/__tests__/HookExecution.spec.tsx
Normal file
126
webview-ui/src/components/chat/__tests__/HookExecution.spec.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import React from "react"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
|
||||
import { HookExecution } from "../HookExecution"
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, _options?: unknown) => key,
|
||||
}))
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
ChevronDown: (props: any) => <svg aria-label="ChevronDown" {...props} />,
|
||||
FishingHook: (props: any) => <svg aria-label="FishingHook" {...props} />,
|
||||
}))
|
||||
|
||||
vi.mock("@src/components/ui", () => ({
|
||||
Button: ({ children, ...props }: any) => (
|
||||
<button type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
StandardTooltip: ({ children }: any) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock("@src/components/common/CodeBlock", () => ({
|
||||
default: ({ source }: { source: string }) => <div data-testid="code-block">{source}</div>,
|
||||
}))
|
||||
|
||||
/**
|
||||
* HookExecution tests
|
||||
*
|
||||
* Verifies:
|
||||
* - `hookExecutionOutputStatus` payload parsing via schema
|
||||
* - filtering by `executionId`
|
||||
* - no crashes on invalid payloads
|
||||
*/
|
||||
describe("HookExecution", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("filters hookExecutionOutputStatus updates by executionId", async () => {
|
||||
render(
|
||||
<HookExecution
|
||||
message={{
|
||||
text: JSON.stringify({
|
||||
executionId: "execA",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
toolName: "Write",
|
||||
command: "echo hi",
|
||||
}),
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Expand so output can render once it arrives.
|
||||
fireEvent.click(screen.getByTestId("hook-execution-toggle"))
|
||||
|
||||
// Wrong executionId: should be ignored.
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "hookExecutionOutputStatus",
|
||||
text: JSON.stringify({
|
||||
executionId: "execB",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
status: "output",
|
||||
command: "echo hi",
|
||||
cwd: "/project",
|
||||
output: "SHOULD_NOT_APPEAR",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.queryByText("SHOULD_NOT_APPEAR")).toBeNull()
|
||||
|
||||
// Matching executionId: should update output.
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "hookExecutionOutputStatus",
|
||||
text: JSON.stringify({
|
||||
executionId: "execA",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
status: "output",
|
||||
command: "echo hi",
|
||||
cwd: "/project",
|
||||
output: "STREAMED_OUTPUT",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Our CodeBlock mock renders code blocks as divs with text content.
|
||||
expect(await screen.findByText("STREAMED_OUTPUT")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("does not crash on invalid hookExecutionOutputStatus payload", () => {
|
||||
render(
|
||||
<HookExecution
|
||||
message={{
|
||||
text: JSON.stringify({
|
||||
executionId: "execA",
|
||||
hookId: "hook_1",
|
||||
event: "PreToolUse",
|
||||
command: "echo hi",
|
||||
}),
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "hookExecutionOutputStatus",
|
||||
text: "not-json",
|
||||
},
|
||||
}),
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
@ -325,7 +325,7 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle, autoExpandHookId, o
|
|||
)
|
||||
// Preserve stable order based on HOOK_EVENT_OPTIONS
|
||||
return HOOK_EVENT_OPTIONS.filter((e) => rawEvents.includes(e))
|
||||
}, [hooksForId])
|
||||
}, [hooksForId, hook.events])
|
||||
|
||||
const eventTooltipText = useCallback(
|
||||
(event: HookEventOption) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue