feat(hooks): fire message_submit + on_error too (#455 observer-only)

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.
This commit is contained in:
Hunter Bown
2026-05-03 07:01:52 -05:00
parent 4310202645
commit e569f2ca99
2 changed files with 35 additions and 14 deletions
+16 -14
View File
@@ -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.
+19
View File
@@ -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());