diff --git a/packages/types/src/__tests__/vscode-extension-host.hookExecutionOutputStatus.spec.ts b/packages/types/src/__tests__/vscode-extension-host.hookExecutionOutputStatus.spec.ts new file mode 100644 index 0000000000..3fb58b04af --- /dev/null +++ b/packages/types/src/__tests__/vscode-extension-host.hookExecutionOutputStatus.spec.ts @@ -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) + }) +}) diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index e2371c9182..3eae290829 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -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 diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index e9be4a8070..37d5076f4e 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -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 + /** * HookExecutionStatusPayload * Sent when hook execution starts, completes, or fails. diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 0402162f67..adf11048e8 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -544,9 +544,19 @@ export class Task extends EventEmitter 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) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5c41038a2e..9e7aeed2bf 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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 } diff --git a/src/services/hooks/HookExecutor.ts b/src/services/hooks/HookExecutor.ts index 1673062561..51adcf2c40 100644 --- a/src/services/hooks/HookExecutor.ts +++ b/src/services/hooks/HookExecutor.ts @@ -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 { 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))) } }) diff --git a/src/services/hooks/HookManager.ts b/src/services/hooks/HookManager.ts index e092795937..a029ec0d41 100644 --- a/src/services/hooks/HookManager.ts +++ b/src/services/hooks/HookManager.ts @@ -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 diff --git a/src/services/hooks/ToolExecutionHooks.ts b/src/services/hooks/ToolExecutionHooks.ts index 2c0113434f..0fefef03d1 100644 --- a/src/services/hooks/ToolExecutionHooks.ts +++ b/src/services/hooks/ToolExecutionHooks.ts @@ -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 +export type UpdateSayCallback = (messageTs: number, type: string, text?: string) => Promise + /** * 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 { - 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() + private hookExecutionCallbackInstance?: NonNullable + + private buildHookExecutionCallback(): NonNullable { + 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, + ) } diff --git a/src/services/hooks/__tests__/HookManager.spec.ts b/src/services/hooks/__tests__/HookManager.spec.ts index be49e5a264..4944338853 100644 --- a/src/services/hooks/__tests__/HookManager.spec.ts +++ b/src/services/hooks/__tests__/HookManager.spec.ts @@ -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") diff --git a/src/services/hooks/__tests__/ToolExecutionHooks.spec.ts b/src/services/hooks/__tests__/ToolExecutionHooks.spec.ts index 9c511586b3..abd5469d2a 100644 --- a/src/services/hooks/__tests__/ToolExecutionHooks.spec.ts +++ b/src/services/hooks/__tests__/ToolExecutionHooks.spec.ts @@ -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", + }), + ) + }) + }) }) diff --git a/src/services/hooks/types.ts b/src/services/hooks/types.ts index f58c4df4cf..46c6636098 100644 --- a/src/services/hooks/types.ts +++ b/src/services/hooks/types.ts @@ -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 } /** diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 14c5aefe9f..90f7cfa881 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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 case "hook_triggered": return (
{ + const initialData = useMemo( + () => safeJsonParse(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(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 + } + + if (status.status === "exited") { + const isSuccess = status.exitCode === 0 + return ( + +
+ + ) + } + + if (status.status === "failed") { + return ( + +
+ + ) + } + + if (status.status === "blocked") { + return ( + +
+ + ) + } + + return null + } + + return ( +
+
+
+ + + {hookId} + + + ({event} + {toolName ? `:${toolName}` : ""}) + + {getStatusIndicator()} +
+
+
+ {status?.modified && Modified} + +
+
+
+ +
+
+ + + {status?.blockMessage && isExpanded && ( +
{status.blockMessage}
+ )} + {status?.error && isExpanded && ( +
{status.error}
+ )} +
+
+
+ ) +} + +HookExecution.displayName = "HookExecution" + +const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean; output: string }) => ( +
+ {output.length > 0 && } +
+) + +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 }) => ( + + {children} + +) diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.hook-execution.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.hook-execution.spec.tsx new file mode 100644 index 0000000000..a6e07ecf9e --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.hook-execution.spec.tsx @@ -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) =>
{message?.text}
, +})) + +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( + + + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + /> + + , + ) + + expect(screen.getByTestId("hook-execution-mock")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/HookExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/HookExecution.spec.tsx new file mode 100644 index 0000000000..ad7e61c4c0 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/HookExecution.spec.tsx @@ -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) => , + FishingHook: (props: any) => , +})) + +vi.mock("@src/components/ui", () => ({ + Button: ({ children, ...props }: any) => ( + + ), + StandardTooltip: ({ children }: any) => <>{children}, +})) + +vi.mock("@src/components/common/CodeBlock", () => ({ + default: ({ source }: { source: string }) =>
{source}
, +})) + +/** + * 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( + , + ) + + // 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( + , + ) + + expect(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "hookExecutionOutputStatus", + text: "not-json", + }, + }), + ) + }).not.toThrow() + }) +}) diff --git a/webview-ui/src/components/settings/HooksSettings.tsx b/webview-ui/src/components/settings/HooksSettings.tsx index 8e101603bd..2feada5326 100644 --- a/webview-ui/src/components/settings/HooksSettings.tsx +++ b/webview-ui/src/components/settings/HooksSettings.tsx @@ -325,7 +325,7 @@ const HookItem: React.FC = ({ 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) => {