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:
Toray Altas 2026-01-18 02:37:21 -05:00
parent caa9fd509c
commit 6e443bf8d4
16 changed files with 1300 additions and 41 deletions

View file

@ -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)
})
})

View file

@ -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

View file

@ -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.

View file

@ -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)

View file

@ -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
}

View file

@ -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)))
}
})

View file

@ -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

View file

@ -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,
)
}

View file

@ -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")

View file

@ -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",
}),
)
})
})
})

View file

@ -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>
}
/**

View file

@ -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

View 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>
)

View file

@ -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()
})
})

View 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()
})
})

View file

@ -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) => {