From e569f2ca99e948589be3d265a5afed40088ad036 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 3 May 2026 07:01:52 -0500 Subject: [PATCH] feat(hooks): fire message_submit + on_error too (#455 observer-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the observer-only slice of #455 by wiring the two remaining `HookEvent` variants that were defined but never fired: * `MessageSubmit` fires from `dispatch_user_message` before the message is handed to the engine. Hook context carries `message` so observers can log every prompt the user submits, redact for compliance audit, or page on `/wipe-database`-style content. Read-only. * `OnError` fires from `apply_engine_error_to_app` before the error cell reaches the transcript. Hook context carries `error`. Useful for paging on auth / billing / invalid- request failures without tailing the audit log. Combined with the prior `tool_call_before` / `tool_call_after` wiring, every `HookEvent` variant now has a live producer: `SessionStart`, `SessionEnd`, `MessageSubmit`, `ToolCallBefore`, `ToolCallAfter`, `ModeChange`, `OnError`. The `/hooks events` listing already enumerates them with their on-fire semantics. Hooks remain read-only observers in this slice. Mutation is v0.8.9 follow-up because it needs a synchronous-gate contract that would change semantics for every hook surface — including the lifecycle events that have shipped for many releases. --- CHANGELOG.md | 30 ++++++++++++++++-------------- crates/tui/src/tui/ui.rs | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e92849ab..24e8ec9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -251,20 +251,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 discover which events to target in `[[hooks.hooks]]` entries without reading source. Ordered lifecycle → per-tool → situational, stable across releases. -- **`tool_call_before` + `tool_call_after` hooks fire** (#455 - observer-only slice) — both events were defined in the - `HookEvent` enum but never fired from production code. The - TUI now fires them from `tool_routing.rs` whenever a tool - starts or finishes. Hook context carries `tool_name`, - `tool_args` (before), or `tool_result` + success flag - (after) — observers can log every tool call, send Slack - pings, or audit. Hooks remain read-only in this slice; - argument / result mutation is a v0.8.9 follow-up because it - needs a synchronous-gate contract that doesn't exist today. - `/hooks events` already enumerates these events; users can - wire them via `[[hooks.hooks]]` entries with - `event = "tool_call_before"` or `event = "tool_call_after"` - immediately. +- **`tool_call_before` / `tool_call_after` / `message_submit` / + `on_error` hooks all fire now** (#455 observer-only slice) — + these events were defined in the `HookEvent` enum but never + fired from production code. Wired through: + `tool_call_before` and `tool_call_after` fire from + `tool_routing.rs`; `message_submit` fires from + `dispatch_user_message` before engine dispatch; `on_error` + fires from `apply_engine_error_to_app` before the error cell + reaches the transcript. Hook contexts populate the relevant + fields (`tool_name` + `tool_args` / `tool_result`, + `message`, `error`). Hooks remain read-only in this slice; + argument / result / message mutation is a v0.8.9 follow-up + because it needs a synchronous-gate contract that doesn't + exist today. Combined with the existing `session_start` / + `session_end` / `mode_change` events, every variant in the + `HookEvent` enum now has a live producer. - **RLM tool family** (#512) — `rlm` tool cards map to `ToolFamily::Rlm` and render `rlm`, not `swarm`. Stale "swarm" wording cleaned out of docs / comments / tests. diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 73440e09..d3cceed7 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2574,6 +2574,16 @@ pub(crate) fn apply_engine_error_to_app( app.streaming_state.reset(); app.streaming_message_index = None; app.streaming_thinking_active_entry = None; + + // #455 (observer-only): fire `on_error` hooks so operators can + // page on auth / billing / invalid-request failures without + // tailing the audit log. Read-only — the hook can react but not + // suppress the error from reaching the transcript. + { + let context = app.base_hook_context().with_error(&message); + let _ = app.execute_hooks(crate::hooks::HookEvent::OnError, &context); + } + app.add_message(HistoryCell::Error { message: message.clone(), severity, @@ -2880,6 +2890,15 @@ async fn dispatch_user_message( engine_handle: &EngineHandle, message: QueuedMessage, ) -> Result<()> { + // #455 (observer-only): fire `message_submit` hooks before + // dispatch. Hooks see the user's display text via the + // `with_message` builder. Read-only — they can log, audit, or + // notify but cannot mutate the message that goes to the engine. + { + let context = app.base_hook_context().with_message(&message.display); + let _ = app.execute_hooks(crate::hooks::HookEvent::MessageSubmit, &context); + } + // Set immediately to prevent double-dispatch before TurnStarted event arrives. app.is_loading = true; app.last_send_at = Some(Instant::now());