From 0d66ef34d1b40100a6f9327e67ab03cccec8bce8 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 00:09:01 -0700 Subject: [PATCH] feat(hooks): add turn_end observer hook Harvested the narrow Rust/docs slice of PR #2578 by @AresNing for #1364. The event uses the maintained structured observer path: JSON stdin, stdout ignored, warn-only failures, and no ability to block or mutate the turn. The hook fires after post-turn app state, usage totals, cost, notification, receipt, and queue-recovery state are updated, before queued follow-up dispatch. Docs, RFC notes, /hooks discovery, and v0.9 tracking now describe the observer-only contract. Co-authored-by: AresNing <49557311+AresNing@users.noreply.github.com> --- CHANGELOG.md | 4 + README.md | 2 +- crates/tui/CHANGELOG.md | 4 + crates/tui/src/commands/hooks.rs | 7 ++ crates/tui/src/hooks.rs | 192 ++++++++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 41 ++++++- docs/CONFIGURATION.md | 55 +++++++++ docs/V0_9_0_EXECUTION_MAP.md | 2 +- docs/rfcs/1364-hooks-lifecycle.md | 11 +- 9 files changed, 313 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b76460c..7ec610e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested commits use GitHub-mappable numeric noreply identities instead of `.local`, placeholder, bot/tool, or raw third-party emails. +- Added a `turn_end` observer hook that fires after post-turn TUI state and + token totals are updated. Hooks receive structured JSON with status, usage, + totals, duration, tool count, and queued-message count on stdin; stdout is + ignored and failures are warn-only (#1364, #2578). - Added rich PlanArtifact support to `update_plan`: Plan mode can now carry grounded objectives, context, sources, critical files, constraints, verification, risks, and handoff notes through the transcript card, Plan diff --git a/README.md b/README.md index 2c2e2578..2bfcf155 100644 --- a/README.md +++ b/README.md @@ -720,7 +720,7 @@ Current and recurring contributors include: - **[yuanchenglu](https://github.com/yuanchenglu)** — Feishu per-chat model switching (#2149) - **[HUQIANTAO](https://github.com/HUQIANTAO)** — Xiaomi balance/status work, stalled-turn recovery, approval intent summaries, mobile smoke/QR support, Claude theme, and broad docs/test/CI coverage (#2257, #2267, #2283, #2384, #2385, #2389, #2403, #2440-#2458, #2460) - **[h3c-hexin](https://github.com/h3c-hexin)** — web-search URL decoding, prompt/instructions override hooks, sub-agent guidance, SSRF fake-IP trust configuration, and prompt-cache-friendly environment placement (#2245, #2311, #2313, #2314, #2354, #2355, #2356) -- **[AresNing](https://github.com/AresNing)** — first-run guide and message-submit hook transform design harvested into the maintained hooks path (#2278, #2318, #2434) +- **[AresNing](https://github.com/AresNing)** — first-run guide, message-submit hook transform design, and turn-end observer hook work harvested into the maintained hooks path (#2278, #2318, #2434, #2578) - **[Implementist](https://github.com/Implementist)** — Volcengine Ark search provider and reliability hardening (#2426, #2429, #2439) - **[lihuan215](https://github.com/lihuan215)** — Unix socket hook sink design harvested into the opt-in hook event path (#2333, #2430) - **[AdityaVG13](https://github.com/AdityaVG13)** — Xiaomi MiMo provider support (#2246) diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 6b76460c..7ec610e1 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested commits use GitHub-mappable numeric noreply identities instead of `.local`, placeholder, bot/tool, or raw third-party emails. +- Added a `turn_end` observer hook that fires after post-turn TUI state and + token totals are updated. Hooks receive structured JSON with status, usage, + totals, duration, tool count, and queued-message count on stdin; stdout is + ignored and failures are warn-only (#1364, #2578). - Added rich PlanArtifact support to `update_plan`: Plan mode can now carry grounded objectives, context, sources, critical files, constraints, verification, risks, and handoff notes through the transcript card, Plan diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index e837e477..d01a52ca 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -43,6 +43,10 @@ fn events() -> CommandResult { let ordered = [ (HookEvent::SessionStart, "fires once when the TUI launches"), (HookEvent::SessionEnd, "fires once on graceful shutdown"), + ( + HookEvent::TurnEnd, + "fires after a turn completes (observer-only)", + ), ( HookEvent::MessageSubmit, "fires before model dispatch; can transform or block submitted text", @@ -146,6 +150,7 @@ fn event_label(event: HookEvent) -> &'static str { HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", + HookEvent::TurnEnd => "turn_end", HookEvent::SubagentSpawn => "subagent_spawn", HookEvent::SubagentComplete => "subagent_complete", HookEvent::ShellEnv => "shell_env", @@ -266,6 +271,7 @@ mod tests { let positions: Vec<(usize, &str)> = [ "session_start", "session_end", + "turn_end", "message_submit", "tool_call_before", "tool_call_after", @@ -310,6 +316,7 @@ mod tests { assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit"); assert_eq!(event_label(HookEvent::ModeChange), "mode_change"); assert_eq!(event_label(HookEvent::OnError), "on_error"); + assert_eq!(event_label(HookEvent::TurnEnd), "turn_end"); assert_eq!(event_label(HookEvent::SubagentSpawn), "subagent_spawn"); assert_eq!( event_label(HookEvent::SubagentComplete), diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index a528bc1a..58fe4f1d 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -7,6 +7,7 @@ //! - Mode changes //! - Message submission //! - Error events +//! - Turn completion //! //! Configuration is done via `[[hooks.hooks]]` in config.toml. @@ -41,6 +42,8 @@ pub enum HookEvent { ModeChange, /// Triggered when an error occurs OnError, + /// Triggered after a turn completes and post-turn state has been updated + TurnEnd, /// Triggered when a sub-agent is spawned SubagentSpawn, /// Triggered when a sub-agent reaches a terminal state @@ -66,6 +69,7 @@ impl HookEvent { HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", + HookEvent::TurnEnd => "turn_end", HookEvent::SubagentSpawn => "subagent_spawn", HookEvent::SubagentComplete => "subagent_complete", HookEvent::ShellEnv => "shell_env", @@ -480,6 +484,28 @@ enum MessageSubmitStdout { Invalid(String), } +/// Post-turn accumulated totals included in the `turn_end` observer payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TurnEndTotals { + pub session_tokens: u32, + pub conversation_tokens: u32, + pub input_tokens: u32, + pub output_tokens: u32, +} + +/// Input used to build the structured `turn_end` observer payload. +pub struct TurnEndPayloadInput<'a> { + pub context: &'a HookContext, + pub turn_id: Option<&'a str>, + pub status: &'a str, + pub error: Option<&'a str>, + pub duration: Duration, + pub usage: &'a crate::models::Usage, + pub totals: TurnEndTotals, + pub tool_count: usize, + pub queued_message_count: usize, +} + /// Executor for running hooks #[derive(Debug, Clone)] pub struct HookExecutor { @@ -1121,6 +1147,41 @@ fn message_submit_payload(context: &HookContext, text: &str) -> serde_json::Valu }) } +pub fn turn_end_payload(input: TurnEndPayloadInput<'_>) -> serde_json::Value { + json!({ + "event": HookEvent::TurnEnd.as_str(), + "session_id": input.context.session_id.as_deref(), + "workspace": input.context.workspace.as_ref().map(|path| path.display().to_string()), + "mode": input.context.mode.as_deref(), + "model": input.context.model.as_deref(), + "turn_id": input.turn_id, + "status": input.status, + "error": input.error, + "duration_ms": duration_ms_saturating(input.duration), + "usage": { + "input_tokens": input.usage.input_tokens, + "output_tokens": input.usage.output_tokens, + "prompt_cache_hit_tokens": input.usage.prompt_cache_hit_tokens, + "prompt_cache_miss_tokens": input.usage.prompt_cache_miss_tokens, + "reasoning_tokens": input.usage.reasoning_tokens, + "reasoning_replay_tokens": input.usage.reasoning_replay_tokens, + }, + "totals": { + "session_tokens": input.totals.session_tokens, + "conversation_tokens": input.totals.conversation_tokens, + "input_tokens": input.totals.input_tokens, + "output_tokens": input.totals.output_tokens, + }, + "tool_count": input.tool_count, + "queued_message_count": input.queued_message_count, + "stop_hook_active": false, + }) +} + +fn duration_ms_saturating(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + fn parse_message_submit_stdout(stdout: &str) -> MessageSubmitStdout { let trimmed = stdout.trim(); if trimmed.is_empty() { @@ -1343,10 +1404,70 @@ NOEQUAL line dropped assert_eq!(HookEvent::SessionStart.as_str(), "session_start"); assert_eq!(HookEvent::ToolCallAfter.as_str(), "tool_call_after"); assert_eq!(HookEvent::ModeChange.as_str(), "mode_change"); + assert_eq!(HookEvent::TurnEnd.as_str(), "turn_end"); assert_eq!(HookEvent::SubagentSpawn.as_str(), "subagent_spawn"); assert_eq!(HookEvent::SubagentComplete.as_str(), "subagent_complete"); } + #[test] + fn turn_end_payload_contains_post_turn_observer_fields() { + let context = HookContext::new() + .with_session_id("sess_test") + .with_workspace(PathBuf::from("/tmp/codewhale")) + .with_mode("agent") + .with_model("deepseek-v4") + .with_tokens(125); + let usage = crate::models::Usage { + input_tokens: 40, + output_tokens: 9, + prompt_cache_hit_tokens: Some(10), + prompt_cache_miss_tokens: Some(30), + reasoning_tokens: Some(4), + reasoning_replay_tokens: Some(2), + server_tool_use: None, + }; + + let payload = super::turn_end_payload(TurnEndPayloadInput { + context: &context, + turn_id: Some("turn_123"), + status: "completed", + error: None, + duration: Duration::from_millis(321), + usage: &usage, + totals: TurnEndTotals { + session_tokens: 125, + conversation_tokens: 100, + input_tokens: 100, + output_tokens: 25, + }, + tool_count: 2, + queued_message_count: 1, + }); + + assert_eq!(payload["event"], "turn_end"); + assert_eq!(payload["session_id"], "sess_test"); + assert_eq!(payload["workspace"], "/tmp/codewhale"); + assert_eq!(payload["mode"], "agent"); + assert_eq!(payload["model"], "deepseek-v4"); + assert_eq!(payload["turn_id"], "turn_123"); + assert_eq!(payload["status"], "completed"); + assert_eq!(payload["error"], serde_json::Value::Null); + assert_eq!(payload["duration_ms"], 321); + assert_eq!(payload["usage"]["input_tokens"], 40); + assert_eq!(payload["usage"]["output_tokens"], 9); + assert_eq!(payload["usage"]["prompt_cache_hit_tokens"], 10); + assert_eq!(payload["usage"]["prompt_cache_miss_tokens"], 30); + assert_eq!(payload["usage"]["reasoning_tokens"], 4); + assert_eq!(payload["usage"]["reasoning_replay_tokens"], 2); + assert_eq!(payload["totals"]["session_tokens"], 125); + assert_eq!(payload["totals"]["conversation_tokens"], 100); + assert_eq!(payload["totals"]["input_tokens"], 100); + assert_eq!(payload["totals"]["output_tokens"], 25); + assert_eq!(payload["tool_count"], 2); + assert_eq!(payload["queued_message_count"], 1); + assert_eq!(payload["stop_hook_active"], false); + } + #[test] fn test_hook_context_to_env_vars() { let ctx = HookContext::new() @@ -1578,6 +1699,76 @@ cat > "{}" assert_eq!(captured["prompt_truncated"], false); } + #[cfg(not(windows))] + #[test] + fn turn_end_observer_hook_receives_stdin_json_and_ignores_stdout_contract() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("turn_end.json"); + let command = write_hook_script( + &dir, + "capture_turn_end.sh", + &format!( + r#"#!/bin/sh +cat > "{}" +printf '%s\n' '{{"text":"stdout is not a mutation contract"}}' +"#, + out.display() + ), + ); + let executor = HookExecutor::new( + HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::TurnEnd, &command)], + ..Default::default() + }, + dir.path().to_path_buf(), + ); + let usage = crate::models::Usage { + input_tokens: 12, + output_tokens: 3, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + reasoning_tokens: None, + reasoning_replay_tokens: None, + server_tool_use: None, + }; + let context = submit_context(&dir).with_tokens(15); + let payload = super::turn_end_payload(TurnEndPayloadInput { + context: &context, + turn_id: Some("turn_observed"), + status: "completed", + error: None, + duration: Duration::from_millis(7), + usage: &usage, + totals: TurnEndTotals { + session_tokens: 15, + conversation_tokens: 15, + input_tokens: 12, + output_tokens: 3, + }, + tool_count: 0, + queued_message_count: 0, + }); + + let results = executor.execute_json_observer(HookEvent::TurnEnd, &context, &payload); + + assert_eq!(results.len(), 1); + assert!(results[0].success); + assert!( + results[0] + .stdout + .contains("stdout is not a mutation contract"), + "stdout is still captured for diagnostics" + ); + let captured: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(out).expect("payload written")) + .expect("valid JSON payload"); + assert_eq!(captured["event"], "turn_end"); + assert_eq!(captured["turn_id"], "turn_observed"); + assert_eq!(captured["totals"]["input_tokens"], 12); + assert_eq!(captured["totals"]["output_tokens"], 3); + } + #[cfg(not(windows))] #[test] fn json_observer_hook_failure_does_not_stop_later_hooks() { @@ -1912,6 +2103,7 @@ exit 7 HookEvent::ToolCallAfter, HookEvent::ModeChange, HookEvent::OnError, + HookEvent::TurnEnd, HookEvent::SubagentSpawn, HookEvent::SubagentComplete, ] { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index a810b671..03d2ee81 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -49,7 +49,7 @@ use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEve use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; use crate::core::ops::{Op, USER_SHELL_TOOL_ID_PREFIX}; -use crate::hooks::{HookEvent, HookExecutor}; +use crate::hooks::{HookEvent, HookExecutor, TurnEndPayloadInput, TurnEndTotals}; use crate::llm_client::LlmClient; use crate::localization::{MessageId, tr}; use crate::models::{ @@ -699,6 +699,41 @@ fn execute_subagent_observer_hook( }); } +fn execute_turn_end_observer_hook( + app: &App, + usage: &Usage, + duration: Duration, + error: Option<&str>, +) { + if !app.hooks.has_hooks_for_event(HookEvent::TurnEnd) { + return; + } + + let context = app.base_hook_context(); + let payload = crate::hooks::turn_end_payload(TurnEndPayloadInput { + context: &context, + turn_id: app.runtime_turn_id.as_deref(), + status: app.runtime_turn_status.as_deref().unwrap_or("unknown"), + error, + duration, + usage, + totals: TurnEndTotals { + session_tokens: app.session.total_tokens, + conversation_tokens: app.session.total_conversation_tokens, + input_tokens: app.session.total_input_tokens, + output_tokens: app.session.total_output_tokens, + }, + tool_count: app.tool_evidence.len(), + queued_message_count: app.queued_message_count(), + }); + let hooks = app.hooks.clone(); + let _ = std::thread::Builder::new() + .name("turn_end-observer-hook".to_string()) + .spawn(move || { + let _ = hooks.execute_json_observer(HookEvent::TurnEnd, &context, &payload); + }); +} + fn bounded_subagent_hook_preview(text: &str) -> (String, bool) { if text.len() <= SUBAGENT_HOOK_PREVIEW_LIMIT { return (text.to_string(), false); @@ -1769,7 +1804,7 @@ async fn run_event_loop( reasoning_replay_tokens: usage.reasoning_replay_tokens, recorded_at: Instant::now(), }); - if let Some(error) = error { + if let Some(error) = error.as_deref() { // Only show "Turn failed:" in the composer status // area when an EngineEvent::Error has NOT already // posted the same message into the transcript. @@ -1940,6 +1975,8 @@ async fn run_event_loop( } } + execute_turn_end_observer_hook(app, &usage, turn_elapsed, error.as_deref()); + if queued_to_send.is_none() { queued_to_send = app.pop_queued_message(); } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 31004ce2..347054d3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -592,6 +592,61 @@ the message. Existing environment variables remain available. `shell_env` hooks keep their existing `KEY=VALUE` stdout contract; the JSON stdout contract applies only to `message_submit`. +### Turn-end observer hooks + +`turn_end` hooks observe the end of each model turn after post-turn +state, usage totals, cost accounting, notifications, receipts, and +queue recovery have been updated. They receive JSON on stdin and are +observer-only: stdout is ignored, failures are logged as warnings, and +the hook cannot block user input, mutate the transcript, or change the +next queued follow-up. + +```toml +[[hooks.hooks]] +event = "turn_end" +command = "~/.codewhale/hooks/turn-audit.sh" +timeout_secs = 2 +continue_on_error = true +``` + +The payload includes common hook metadata plus post-turn accounting: + +```json +{ + "event": "turn_end", + "session_id": "sess_12345678", + "workspace": "/path/to/workspace", + "mode": "agent", + "model": "deepseek-chat", + "turn_id": "turn_12345678", + "status": "completed", + "error": null, + "duration_ms": 1834, + "usage": { + "input_tokens": 1200, + "output_tokens": 180, + "prompt_cache_hit_tokens": 900, + "prompt_cache_miss_tokens": 300, + "reasoning_tokens": null, + "reasoning_replay_tokens": null + }, + "totals": { + "session_tokens": 1380, + "conversation_tokens": 1380, + "input_tokens": 1200, + "output_tokens": 180 + }, + "tool_count": 2, + "queued_message_count": 1, + "stop_hook_active": false +} +``` + +For `interrupted` or `failed` turns, `status` reflects that terminal +state and `error` carries the engine error string when one is available. +`stop_hook_active` is reserved for future re-entry protection and is +currently always `false`. + ### Sub-agent lifecycle hooks `subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 3ee65914..d299d59e 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -124,7 +124,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2529 workspace shell opt-in | Draft/conflicting | Review with permissions/sandbox stabilization. | | #2530 mention depth cap hint | Draft/mergeable | Already present locally as `a97675824` and `29f57665e`; close/comment after branch is public. | | #2576 PrefixCacheChange events | Mergeable | Already present locally through `29acb87a9d`; close/comment after branch is public or merged. | -| #2578 turn_end observer hook | Conflicting | Defer to hook lifecycle lane. | +| #2578 turn_end observer hook | Conflicting / locally harvested | Narrow Rust/docs slice landed in the hook lifecycle lane: `turn_end` now uses the existing structured observer path, fires after post-turn state updates and before queued follow-up dispatch, and includes status, usage, totals, duration, tool count, and queued-message count. Close/comment after branch is public, crediting @AresNing and #1364 reporter @esinecan. | | #2579 AppendLog session messages | Conflicting | Defer; large architectural change. | | #2581 provider fallback chain design doc | Mergeable / empty diff | Manually harvested into `docs/rfcs/2574-provider-fallback-chain.md`; close original PR after branch is public, keep #2574 open for implementation. | | #2623 plan prompt modal scroll support | Mergeable | Already harvested into the 22-commit stack. Comment/close original after integration branch is public. | diff --git a/docs/rfcs/1364-hooks-lifecycle.md b/docs/rfcs/1364-hooks-lifecycle.md index f7f759c1..6256f13d 100644 --- a/docs/rfcs/1364-hooks-lifecycle.md +++ b/docs/rfcs/1364-hooks-lifecycle.md @@ -64,6 +64,13 @@ Non-goals: - no blocking of user input - no transcript mutation from `turn_end` +Implementation note for the v0.9 branch: the narrow #2578 harvest uses the +shared structured observer path introduced for sub-agent lifecycle hooks. It +fires before queued follow-up dispatch, after queue-recovery state is known, so +the payload can report the queued-message count without letting a hook change +what gets sent next. Stdout is ignored for `turn_end`; only `message_submit` +has a stdout mutation contract. + ### PR 3: Subagent lifecycle observer hooks Expose subagent start and completion as observer-only hook events. @@ -251,7 +258,9 @@ transcript content in the first version. - Existing observer-only hooks keep working. - Existing env vars remain available. - `shell_env` keeps its existing stdout `KEY=VALUE` contract. -- Structured stdout is interpreted only by `message_submit` in PR 1. +- Structured stdout is interpreted only by `message_submit` in PR 1. Structured + observer hooks such as `turn_end`, `subagent_spawn`, and `subagent_complete` + receive JSON on stdin, but their stdout is ignored by the caller. ## 6. Review checkpoints