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>
This commit is contained in:
Hunter B
2026-06-04 00:09:01 -07:00
parent 586640a437
commit 0d66ef34d1
9 changed files with 313 additions and 5 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -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)
+4
View File
@@ -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
+7
View File
@@ -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),
+192
View File
@@ -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,
] {
+39 -2
View File
@@ -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();
}
+55
View File
@@ -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
+1 -1
View File
@@ -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. |
+10 -1
View File
@@ -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