[hooks] turn_id extension for Stop & UserPromptSubmit (#15118)

## Description

Adding an extension to the spec that exposes the turn_id to hook
scripts. This is a codex-specific mechanic that allows connecting the
hook runs with particular turns

## Testing

hooks config / sample hooks to use. Extract this, rename codex ->
.codex, and place this into a repo or your home folder. It includes:
config.toml that enables hooks, hooks.json, and sample python hooks:


[codex.zip](https://github.com/user-attachments/files/26102671/codex.zip)

example run (note the turn_ids change between turns):

```
› hello


• Running SessionStart hook: lighting the observatory

SessionStart hook (completed)
  warning: Hi, I'm a session start hook for wizard-tower (startup).
  hook context: Startup context: A wimboltine stonpet is an exotic cuisine from hyperspace

• Running UserPromptSubmit hook: lighting the observatory lanterns

UserPromptSubmit hook (completed)
  warning: wizard-tower UserPromptSubmit demo inspected: hello for turn: 019d036d-c7fa-72d2-b6fd-
78878bfe34e4
  hook context: Wizard Tower UserPromptSubmit demo fired. For this reply only, include the exact
phrase 'observatory lanterns lit' near the end.

• Aloha! Grateful to be here and ready to build with you. Show me what you want to tackle in wizard-
  tower, and we’ll surf the next wave together. observatory lanterns lit

• Running Stop hook: back to shore

Stop hook (completed)
  warning: Wizard Tower Stop hook reviewed the completed reply (170 chars) for turn: 019d036d-c7fa-
72d2-b6fd-78878bfe34e4


› what's a stonpet?


• Running UserPromptSubmit hook: lighting the observatory lanterns

UserPromptSubmit hook (completed)
  warning: wizard-tower UserPromptSubmit demo inspected: what's a stonpet? for turn: 019d036e-3164-
72c3-a170-98925564c4fc
  hook context: Wizard Tower UserPromptSubmit demo fired. For this reply only, include the exact
phrase 'observatory lanterns lit' near the end.

• A stonpet isn’t a standard real-world word, brah. In our shared context here, a wimboltine stonpet
  is an exotic cuisine from hyperspace, so “stonpet” sounds like the dish or food itself.

  If you want, we can totally invent the lore for it next. observatory lanterns lit

• Running Stop hook: back to shore

Stop hook (completed)
  warning: Wizard Tower Stop hook reviewed the completed reply (271 chars) for turn: 019d036e-3164-
72c3-a170-98925564c4fc
```
This commit is contained in:
Andrei Eternal 2026-03-18 21:48:31 -07:00 committed by GitHub
parent b14689df3b
commit 42e932d7bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 177 additions and 65 deletions

View file

@ -88,15 +88,20 @@ fn write_user_prompt_submit_hook(
additional_context: &str,
) -> Result<()> {
let script_path = home.join("user_prompt_submit_hook.py");
let log_path = home.join("user_prompt_submit_hook_log.jsonl");
let log_path = log_path.display();
let blocked_prompt_json =
serde_json::to_string(blocked_prompt).context("serialize blocked prompt for test")?;
let additional_context_json = serde_json::to_string(additional_context)
.context("serialize user prompt submit additional context for test")?;
let script = format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
if payload.get("prompt") == {blocked_prompt_json}:
print(json.dumps({{
@ -202,6 +207,15 @@ fn read_session_start_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>>
.collect()
}
fn read_user_prompt_submit_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
fs::read_to_string(home.join("user_prompt_submit_hook_log.jsonl"))
.context("read user prompt submit hook log")?
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).context("parse user prompt submit hook log line"))
.collect()
}
fn ev_message_item_done(id: &str, text: &str) -> Value {
serde_json::json!({
"type": "response.output_item.done",
@ -305,6 +319,31 @@ async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> {
let hook_inputs = read_stop_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 3);
let stop_turn_ids = hook_inputs
.iter()
.map(|input| {
input["turn_id"]
.as_str()
.expect("stop hook input turn_id")
.to_string()
})
.collect::<Vec<_>>();
assert!(
stop_turn_ids.iter().all(|turn_id| !turn_id.is_empty()),
"stop hook turn ids should be non-empty",
);
let first_stop_turn_id = stop_turn_ids
.first()
.expect("stop hook inputs should include a first turn id")
.clone();
assert_eq!(
stop_turn_ids,
vec![
first_stop_turn_id.clone(),
first_stop_turn_id.clone(),
first_stop_turn_id,
],
);
assert_eq!(
hook_inputs
.iter()
@ -508,6 +547,30 @@ async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn()
"second request should include the accepted prompt",
);
let hook_inputs = read_user_prompt_submit_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 2);
assert_eq!(
hook_inputs
.iter()
.map(|input| {
input["prompt"]
.as_str()
.expect("user prompt submit hook prompt")
.to_string()
})
.collect::<Vec<_>>(),
vec![
"blocked first prompt".to_string(),
"second prompt".to_string()
],
);
assert!(
hook_inputs.iter().all(|input| input["turn_id"]
.as_str()
.is_some_and(|turn_id| !turn_id.is_empty())),
"blocked and accepted prompt hooks should both receive a non-empty turn_id",
);
Ok(())
}
@ -624,6 +687,50 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu
"second request should not include the blocked queued prompt",
);
let hook_inputs = read_user_prompt_submit_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 3);
assert_eq!(
hook_inputs
.iter()
.map(|input| {
input["prompt"]
.as_str()
.expect("queued prompt hook prompt")
.to_string()
})
.collect::<Vec<_>>(),
vec![
"initial prompt".to_string(),
"accepted queued prompt".to_string(),
"blocked queued prompt".to_string(),
],
);
let queued_turn_ids = hook_inputs
.iter()
.map(|input| {
input["turn_id"]
.as_str()
.expect("queued prompt hook turn_id")
.to_string()
})
.collect::<Vec<_>>();
assert!(
queued_turn_ids.iter().all(|turn_id| !turn_id.is_empty()),
"queued prompt hook turn ids should be non-empty",
);
let first_queued_turn_id = queued_turn_ids
.first()
.expect("queued prompt hook inputs should include a first turn id")
.clone();
assert_eq!(
queued_turn_ids,
vec![
first_queued_turn_id.clone(),
first_queued_turn_id.clone(),
first_queued_turn_id,
],
);
server.shutdown().await;
Ok(())
}

View file

@ -41,6 +41,10 @@
},
"transcript_path": {
"$ref": "#/definitions/NullableString"
},
"turn_id": {
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
"type": "string"
}
},
"required": [
@ -51,7 +55,8 @@
"permission_mode",
"session_id",
"stop_hook_active",
"transcript_path"
"transcript_path",
"turn_id"
],
"title": "stop.command.input",
"type": "object"

View file

@ -38,6 +38,10 @@
},
"transcript_path": {
"$ref": "#/definitions/NullableString"
},
"turn_id": {
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
"type": "string"
}
},
"required": [
@ -47,7 +51,8 @@
"permission_mode",
"prompt",
"session_id",
"transcript_path"
"transcript_path",
"turn_id"
],
"title": "user-prompt-submit.command.input",
"type": "object"

View file

@ -14,6 +14,7 @@ use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::NullableString;
use crate::schema::StopCommandInput;
#[derive(Debug, Clone)]
@ -75,15 +76,17 @@ pub(crate) async fn run(
};
}
let input_json = match serde_json::to_string(&StopCommandInput::new(
request.session_id.to_string(),
request.transcript_path.clone(),
request.cwd.display().to_string(),
request.model.clone(),
request.permission_mode.clone(),
request.stop_hook_active,
request.last_assistant_message.clone(),
)) {
let input_json = match serde_json::to_string(&StopCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
transcript_path: NullableString::from_path(request.transcript_path.clone()),
cwd: request.cwd.display().to_string(),
hook_event_name: "Stop".to_string(),
model: request.model.clone(),
permission_mode: request.permission_mode.clone(),
stop_hook_active: request.stop_hook_active,
last_assistant_message: NullableString::from_string(request.last_assistant_message.clone()),
}) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(common::serialization_failure_hook_events(

View file

@ -14,6 +14,7 @@ use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::NullableString;
use crate::schema::UserPromptSubmitCommandInput;
#[derive(Debug, Clone)]
@ -75,14 +76,16 @@ pub(crate) async fn run(
};
}
let input_json = match serde_json::to_string(&UserPromptSubmitCommandInput::new(
request.session_id.to_string(),
request.transcript_path.clone(),
request.cwd.display().to_string(),
request.model.clone(),
request.permission_mode.clone(),
request.prompt.clone(),
)) {
let input_json = match serde_json::to_string(&UserPromptSubmitCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
transcript_path: NullableString::from_path(request.transcript_path.clone()),
cwd: request.cwd.display().to_string(),
hook_event_name: "UserPromptSubmit".to_string(),
model: request.model.clone(),
permission_mode: request.permission_mode.clone(),
prompt: request.prompt.clone(),
}) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(common::serialization_failure_hook_events(

View file

@ -25,11 +25,11 @@ const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json";
pub(crate) struct NullableString(Option<String>);
impl NullableString {
fn from_path(path: Option<PathBuf>) -> Self {
pub(crate) fn from_path(path: Option<PathBuf>) -> Self {
Self(path.map(|path| path.display().to_string()))
}
fn from_string(value: Option<String>) -> Self {
pub(crate) fn from_string(value: Option<String>) -> Self {
Self(value)
}
}
@ -178,6 +178,8 @@ impl SessionStartCommandInput {
#[schemars(rename = "user-prompt-submit.command.input")]
pub(crate) struct UserPromptSubmitCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "user_prompt_submit_hook_event_name_schema")]
@ -188,32 +190,13 @@ pub(crate) struct UserPromptSubmitCommandInput {
pub prompt: String,
}
impl UserPromptSubmitCommandInput {
pub(crate) fn new(
session_id: impl Into<String>,
transcript_path: Option<PathBuf>,
cwd: impl Into<String>,
model: impl Into<String>,
permission_mode: impl Into<String>,
prompt: impl Into<String>,
) -> Self {
Self {
session_id: session_id.into(),
transcript_path: NullableString::from_path(transcript_path),
cwd: cwd.into(),
hook_event_name: "UserPromptSubmit".to_string(),
model: model.into(),
permission_mode: permission_mode.into(),
prompt: prompt.into(),
}
}
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "stop.command.input")]
pub(crate) struct StopCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "stop_hook_event_name_schema")]
@ -225,29 +208,6 @@ pub(crate) struct StopCommandInput {
pub last_assistant_message: NullableString,
}
impl StopCommandInput {
pub(crate) fn new(
session_id: impl Into<String>,
transcript_path: Option<PathBuf>,
cwd: impl Into<String>,
model: impl Into<String>,
permission_mode: impl Into<String>,
stop_hook_active: bool,
last_assistant_message: Option<String>,
) -> Self {
Self {
session_id: session_id.into(),
transcript_path: NullableString::from_path(transcript_path),
cwd: cwd.into(),
hook_event_name: "Stop".to_string(),
model: model.into(),
permission_mode: permission_mode.into(),
stop_hook_active,
last_assistant_message: NullableString::from_string(last_assistant_message),
}
}
}
pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
let generated_dir = schema_root.join(GENERATED_DIR);
ensure_empty_dir(&generated_dir)?;
@ -390,10 +350,14 @@ mod tests {
use super::SESSION_START_OUTPUT_FIXTURE;
use super::STOP_INPUT_FIXTURE;
use super::STOP_OUTPUT_FIXTURE;
use super::StopCommandInput;
use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE;
use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE;
use super::UserPromptSubmitCommandInput;
use super::schema_json;
use super::write_schema_fixtures;
use pretty_assertions::assert_eq;
use serde_json::Value;
use tempfile::TempDir;
fn expected_fixture(name: &str) -> &'static str {
@ -445,4 +409,29 @@ mod tests {
assert_eq!(expected, actual, "fixture should match generated schema");
}
}
#[test]
fn turn_scoped_hook_inputs_include_codex_turn_id_extension() {
// Codex intentionally diverges from Claude's public hook docs here so
// internal hook consumers can key off the active turn.
let user_prompt_submit: Value = serde_json::from_slice(
&schema_json::<UserPromptSubmitCommandInput>()
.expect("serialize user prompt submit input schema"),
)
.expect("parse user prompt submit input schema");
let stop: Value = serde_json::from_slice(
&schema_json::<StopCommandInput>().expect("serialize stop input schema"),
)
.expect("parse stop input schema");
for schema in [&user_prompt_submit, &stop] {
assert_eq!(schema["properties"]["turn_id"]["type"], "string");
assert!(
schema["required"]
.as_array()
.expect("schema required fields")
.contains(&Value::String("turn_id".to_string()))
);
}
}
}