Add subagent identity to hook inputs

This commit is contained in:
Abhinav Vedmala
2026-05-15 18:42:10 +00:00
parent 48110f91dc
commit b2e51b85ee
17 changed files with 281 additions and 14 deletions

View File

@@ -16,6 +16,7 @@ use codex_hooks::SessionStartOutcome;
use codex_hooks::SessionStartTarget;
use codex_hooks::StopHookTarget;
use codex_hooks::StopOutcome;
use codex_hooks::SubagentHookContext;
use codex_hooks::UserPromptSubmitOutcome;
use codex_hooks::UserPromptSubmitRequest;
use codex_otel::HOOK_RUN_DURATION_METRIC;
@@ -127,11 +128,11 @@ pub(crate) async fn run_pending_session_start_hooks(
codex_hooks::SessionStartSource::Startup
) =>
{
let metadata = subagent_hook_metadata(sess, agent_role);
let context = subagent_hook_context(sess, agent_role);
SessionStartTarget::SubagentStart {
turn_id: turn_context.sub_id.clone(),
agent_id: metadata.agent_id,
agent_type: metadata.agent_type,
agent_id: context.agent_id,
agent_type: context.agent_type,
}
}
SessionSource::SubAgent(_) => return false,
@@ -176,6 +177,7 @@ pub(crate) async fn run_pre_tool_use_hooks(
let request = PreToolUseRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
@@ -236,6 +238,7 @@ pub(crate) async fn run_permission_request_hooks(
let request = PermissionRequestRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
transcript_path: sess.hook_transcript_path().await,
@@ -277,6 +280,7 @@ pub(crate) async fn run_post_tool_use_hooks(
let request = PostToolUseRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
@@ -309,7 +313,7 @@ pub(crate) async fn run_turn_stop_hooks(
parent_thread_id,
..
}) => {
let metadata = subagent_hook_metadata(sess, agent_role);
let context = subagent_hook_context(sess, agent_role);
let agent_transcript_path = sess.hook_transcript_path().await;
let parent_transcript_path = match sess
.services
@@ -333,8 +337,8 @@ pub(crate) async fn run_turn_stop_hooks(
};
(
StopHookTarget::SubagentStop {
agent_id: metadata.agent_id,
agent_type: metadata.agent_type,
agent_id: context.agent_id,
agent_type: context.agent_type,
agent_transcript_path,
},
parent_transcript_path,
@@ -371,6 +375,7 @@ pub(crate) async fn run_pre_compact_hooks(
let request = codex_hooks::PreCompactRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
@@ -409,6 +414,7 @@ pub(crate) async fn run_post_compact_hooks(
let request = codex_hooks::PostCompactRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
@@ -435,6 +441,7 @@ pub(crate) async fn run_user_prompt_submit_hooks(
let request = UserPromptSubmitRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
@@ -688,16 +695,20 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String {
.to_string()
}
struct SubagentHookMetadata {
agent_id: String,
agent_type: String,
fn thread_spawn_subagent_hook_context(
sess: &Arc<Session>,
turn_context: &TurnContext,
) -> Option<SubagentHookContext> {
match &turn_context.session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => {
Some(subagent_hook_context(sess, agent_role))
}
_ => None,
}
}
fn subagent_hook_metadata(
sess: &Arc<Session>,
agent_role: &Option<String>,
) -> SubagentHookMetadata {
SubagentHookMetadata {
fn subagent_hook_context(sess: &Arc<Session>, agent_role: &Option<String>) -> SubagentHookContext {
SubagentHookContext {
agent_id: sess.thread_id().to_string(),
agent_type: agent_role
.clone()

View File

@@ -161,6 +161,21 @@ print(json.dumps({{"hookSpecificOutput": {{"hookEventName": "SubagentStart", "ad
start_log_path = start_log_path.display(),
);
let user_prompt_submit_script_path = home.join("user_prompt_submit_hook.py");
let user_prompt_submit_log_path = home.join("user_prompt_submit_hook_log.jsonl");
let user_prompt_submit_script = format!(
r#"import json
from pathlib import Path
import sys
log_path = Path(r"{user_prompt_submit_log_path}")
payload = json.load(sys.stdin)
with log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
"#,
user_prompt_submit_log_path = user_prompt_submit_log_path.display(),
);
let subagent_stop_script_path = home.join("subagent_stop_hook.py");
let subagent_stop_log_path = home.join("subagent_stop_hook_log.jsonl");
let prompts_json = serde_json::to_string(stop_prompts)?;
@@ -222,6 +237,12 @@ print(json.dumps({{"systemMessage": "root stop complete"}}))
"command": format!("python3 {}", start_script_path.display()),
}]
}],
"UserPromptSubmit": [{
"hooks": [{
"type": "command",
"command": format!("python3 {}", user_prompt_submit_script_path.display()),
}]
}],
"SubagentStop": [{
"matcher": subagent_stop_matcher,
"hooks": [{
@@ -240,6 +261,7 @@ print(json.dumps({{"systemMessage": "root stop complete"}}))
fs::write(&session_start_script_path, session_start_script)?;
fs::write(&start_script_path, start_script)?;
fs::write(&user_prompt_submit_script_path, user_prompt_submit_script)?;
fs::write(&subagent_stop_script_path, subagent_stop_script)?;
fs::write(&stop_script_path, stop_script)?;
fs::write(home.join("hooks.json"), hooks.to_string())?;
@@ -526,6 +548,25 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<(
Some(spawned_id.as_str())
);
let user_prompt_submit_inputs =
read_hook_log(test.codex_home_path(), "user_prompt_submit_hook_log.jsonl")?;
let parent_prompt_input = user_prompt_submit_inputs
.iter()
.find(|input| input["prompt"].as_str() == Some(TURN_1_PROMPT))
.expect("parent prompt submit hook input should be logged");
assert_eq!(parent_prompt_input.get("agent_id"), None);
assert_eq!(parent_prompt_input.get("agent_type"), None);
let child_prompt_input = user_prompt_submit_inputs
.iter()
.find(|input| input["prompt"].as_str() == Some(CHILD_PROMPT))
.expect("child prompt submit hook input should be logged");
assert_eq!(
child_prompt_input["agent_id"].as_str(),
Some(spawned_id.as_str())
);
assert_eq!(child_prompt_input["agent_type"].as_str(), Some("worker"));
let session_start_inputs =
read_hook_log(test.codex_home_path(), "session_start_hook_log.jsonl")?;
assert_eq!(session_start_inputs.len(), 1);

View File

@@ -10,6 +10,12 @@
}
},
"properties": {
"agent_id": {
"type": "string"
},
"agent_type": {
"type": "string"
},
"cwd": {
"type": "string"
},

View File

@@ -10,6 +10,12 @@
}
},
"properties": {
"agent_id": {
"type": "string"
},
"agent_type": {
"type": "string"
},
"cwd": {
"type": "string"
},

View File

@@ -10,6 +10,12 @@
}
},
"properties": {
"agent_id": {
"type": "string"
},
"agent_type": {
"type": "string"
},
"cwd": {
"type": "string"
},

View File

@@ -10,6 +10,12 @@
}
},
"properties": {
"agent_id": {
"type": "string"
},
"agent_type": {
"type": "string"
},
"cwd": {
"type": "string"
},

View File

@@ -10,6 +10,12 @@
}
},
"properties": {
"agent_id": {
"type": "string"
},
"agent_type": {
"type": "string"
},
"cwd": {
"type": "string"
},

View File

@@ -10,6 +10,12 @@
}
},
"properties": {
"agent_id": {
"type": "string"
},
"agent_type": {
"type": "string"
},
"cwd": {
"type": "string"
},

View File

@@ -224,6 +224,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
subagent: None,
cwd: cwd.clone(),
transcript_path: None,
model: "gpt-test".to_string(),
@@ -240,6 +241,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
.run_pre_tool_use(PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
subagent: None,
cwd,
transcript_path: None,
model: "gpt-test".to_string(),
@@ -311,6 +313,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() {
.run_pre_tool_use(PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
subagent: None,
cwd: cwd(),
transcript_path: None,
model: "gpt-test".to_string(),
@@ -696,6 +699,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() {
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
subagent: None,
cwd,
transcript_path: None,
model: "gpt-test".to_string(),
@@ -1101,6 +1105,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
subagent: None,
cwd,
transcript_path: None,
model: "gpt-test".to_string(),
@@ -1186,6 +1191,7 @@ print(json.dumps({
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
subagent: None,
cwd: cwd(),
transcript_path: None,
model: "gpt-test".to_string(),
@@ -1217,6 +1223,7 @@ print(json.dumps({
.run_pre_tool_use(PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
subagent: None,
cwd: cwd(),
transcript_path: None,
model: "gpt-test".to_string(),

View File

@@ -8,6 +8,13 @@ use codex_protocol::protocol::HookRunSummary;
use crate::engine::ConfiguredHandler;
use crate::engine::dispatcher;
/// Identifies a thread-spawned subagent when a normal hook runs inside it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubagentHookContext {
pub agent_id: String,
pub agent_type: String,
}
pub(crate) fn join_text_chunks(chunks: Vec<String>) -> Option<String> {
if chunks.is_empty() {
None

View File

@@ -17,11 +17,13 @@ use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::PostCompactCommandInput;
use crate::schema::PreCompactCommandInput;
use crate::schema::SubagentCommandInputFields;
#[derive(Debug, Clone)]
pub struct PreCompactRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub subagent: Option<common::SubagentHookContext>,
pub cwd: AbsolutePathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
@@ -32,6 +34,7 @@ pub struct PreCompactRequest {
pub struct PostCompactRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub subagent: Option<common::SubagentHookContext>,
pub cwd: AbsolutePathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
@@ -120,9 +123,12 @@ pub(crate) async fn run_pre(
}
fn pre_command_input_json(request: &PreCompactRequest) -> Result<String, serde_json::Error> {
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
serde_json::to_string(&PreCompactCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
agent_type: subagent.agent_type,
transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
cwd: request.cwd.display().to_string(),
hook_event_name: "PreCompact".to_string(),
@@ -199,9 +205,12 @@ pub(crate) async fn run_post(
}
fn post_command_input_json(request: &PostCompactRequest) -> Result<String, serde_json::Error> {
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
serde_json::to_string(&PostCompactCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
agent_type: subagent.agent_type,
transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
cwd: request.cwd.display().to_string(),
hook_event_name: "PostCompact".to_string(),
@@ -563,6 +572,7 @@ mod tests {
session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000001")
.expect("valid thread id"),
turn_id: "turn-1".to_string(),
subagent: None,
cwd: test_path_buf("/tmp").abs(),
transcript_path: None,
model: "gpt-test".to_string(),
@@ -575,6 +585,7 @@ mod tests {
session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000002")
.expect("valid thread id"),
turn_id: "turn-1".to_string(),
subagent: None,
cwd: test_path_buf("/tmp").abs(),
transcript_path: None,
model: "gpt-test".to_string(),

View File

@@ -22,6 +22,7 @@ use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::PermissionRequestCommandInput;
use crate::schema::SubagentCommandInputFields;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
@@ -35,6 +36,7 @@ use serde_json::Value;
pub struct PermissionRequestRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub subagent: Option<common::SubagentHookContext>,
pub cwd: PathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
@@ -168,9 +170,12 @@ fn resolve_permission_request_decision<'a>(
}
fn build_command_input(request: &PermissionRequestRequest) -> PermissionRequestCommandInput {
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
PermissionRequestCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
agent_type: subagent.agent_type,
transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
cwd: request.cwd.display().to_string(),
hook_event_name: "PermissionRequest".to_string(),

View File

@@ -17,11 +17,13 @@ use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::PostToolUseCommandInput;
use crate::schema::SubagentCommandInputFields;
#[derive(Debug, Clone)]
pub struct PostToolUseRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub subagent: Option<common::SubagentHookContext>,
pub cwd: AbsolutePathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
@@ -148,9 +150,12 @@ pub(crate) async fn run(
/// events across processes. Shell-like tools pass `{ "command": ... }` as
/// `tool_input`; MCP tools pass their resolved JSON arguments.
fn command_input_json(request: &PostToolUseRequest) -> Result<String, serde_json::Error> {
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
serde_json::to_string(&PostToolUseCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
agent_type: subagent.agent_type,
transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
cwd: request.cwd.display().to_string(),
hook_event_name: "PostToolUse".to_string(),
@@ -571,6 +576,7 @@ mod tests {
super::PostToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
subagent: None,
cwd: test_path_buf("/tmp").abs(),
transcript_path: None,
model: "gpt-test".to_string(),

View File

@@ -17,11 +17,13 @@ use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::PreToolUseCommandInput;
use crate::schema::SubagentCommandInputFields;
#[derive(Debug, Clone)]
pub struct PreToolUseRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub subagent: Option<common::SubagentHookContext>,
pub cwd: AbsolutePathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
@@ -166,9 +168,12 @@ fn latest_updated_input(
/// stable. Shell-like tools pass `{ "command": ... }` as `tool_input`; MCP
/// tools pass their resolved JSON arguments.
fn command_input_json(request: &PreToolUseRequest) -> Result<String, serde_json::Error> {
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
serde_json::to_string(&PreToolUseCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
agent_type: subagent.agent_type,
transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
cwd: request.cwd.display().to_string(),
hook_event_name: "PreToolUse".to_string(),
@@ -763,6 +768,7 @@ mod tests {
super::PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
subagent: None,
cwd: test_path_buf("/tmp").abs(),
transcript_path: None,
model: "gpt-test".to_string(),

View File

@@ -16,12 +16,14 @@ use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::NullableString;
use crate::schema::SubagentCommandInputFields;
use crate::schema::UserPromptSubmitCommandInput;
#[derive(Debug, Clone)]
pub struct UserPromptSubmitRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub subagent: Option<common::SubagentHookContext>,
pub cwd: AbsolutePathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
@@ -77,9 +79,12 @@ pub(crate) async fn run(
};
}
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
let input_json = match serde_json::to_string(&UserPromptSubmitCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
agent_type: subagent.agent_type,
transcript_path: NullableString::from_path(request.transcript_path.clone()),
cwd: request.cwd.display().to_string(),
hook_event_name: "UserPromptSubmit".to_string(),

View File

@@ -14,6 +14,7 @@ pub use config_rules::hook_states_from_stack;
pub use declarations::PluginHookDeclaration;
pub use declarations::plugin_hook_declarations;
pub use engine::HookListEntry;
pub use events::common::SubagentHookContext;
/// Hook event names as they appear in hooks JSON and config files.
pub const HOOK_EVENT_NAMES: [&str; 10] = [
"PreToolUse",

View File

@@ -12,6 +12,8 @@ use serde_json::Value;
use std::path::Path;
use std::path::PathBuf;
use crate::events::common::SubagentHookContext;
const GENERATED_DIR: &str = "generated";
const POST_TOOL_USE_INPUT_FIXTURE: &str = "post-tool-use.command.input.schema.json";
const POST_TOOL_USE_OUTPUT_FIXTURE: &str = "post-tool-use.command.output.schema.json";
@@ -61,6 +63,24 @@ impl JsonSchema for NullableString {
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct SubagentCommandInputFields {
pub agent_id: Option<String>,
pub agent_type: Option<String>,
}
impl From<Option<&SubagentHookContext>> for SubagentCommandInputFields {
fn from(value: Option<&SubagentHookContext>) -> Self {
match value {
Some(context) => Self {
agent_id: Some(context.agent_id.clone()),
agent_type: Some(context.agent_type.clone()),
},
None => Self::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
@@ -251,6 +271,10 @@ pub(crate) struct PreToolUseCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "pre_tool_use_hook_event_name_schema")]
@@ -270,6 +294,10 @@ pub(crate) struct PermissionRequestCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "permission_request_hook_event_name_schema")]
@@ -288,6 +316,10 @@ pub(crate) struct PostToolUseCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "post_tool_use_hook_event_name_schema")]
@@ -308,6 +340,10 @@ pub(crate) struct PreCompactCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "pre_compact_hook_event_name_schema")]
@@ -324,6 +360,10 @@ pub(crate) struct PostCompactCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "post_compact_hook_event_name_schema")]
@@ -486,6 +526,10 @@ pub(crate) struct UserPromptSubmitCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "user_prompt_submit_hook_event_name_schema")]
@@ -761,6 +805,7 @@ fn default_continue() -> bool {
#[cfg(test)]
mod tests {
use super::NullableString;
use super::PERMISSION_REQUEST_INPUT_FIXTURE;
use super::PERMISSION_REQUEST_OUTPUT_FIXTURE;
use super::POST_COMPACT_INPUT_FIXTURE;
@@ -785,6 +830,7 @@ mod tests {
use super::SUBAGENT_STOP_INPUT_FIXTURE;
use super::SUBAGENT_STOP_OUTPUT_FIXTURE;
use super::StopCommandInput;
use super::SubagentCommandInputFields;
use super::SubagentStartCommandInput;
use super::SubagentStopCommandInput;
use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE;
@@ -792,8 +838,10 @@ mod tests {
use super::UserPromptSubmitCommandInput;
use super::schema_json;
use super::write_schema_fixtures;
use crate::events::common::SubagentHookContext;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;
fn expected_fixture(name: &str) -> &'static str {
@@ -968,4 +1016,87 @@ mod tests {
);
}
}
#[test]
fn subagent_context_fields_are_optional_for_hooks_that_run_inside_subagents() {
let schemas = [
schema_json::<PreToolUseCommandInput>().expect("serialize pre tool use input schema"),
schema_json::<PermissionRequestCommandInput>()
.expect("serialize permission request input schema"),
schema_json::<PostToolUseCommandInput>().expect("serialize post tool use input schema"),
schema_json::<PreCompactCommandInput>().expect("serialize pre compact input schema"),
schema_json::<PostCompactCommandInput>().expect("serialize post compact input schema"),
schema_json::<UserPromptSubmitCommandInput>()
.expect("serialize user prompt submit input schema"),
];
for schema in schemas {
let schema: Value = serde_json::from_slice(&schema).expect("parse hook input schema");
assert_eq!(schema["properties"]["agent_id"]["type"], "string");
assert_eq!(schema["properties"]["agent_type"]["type"], "string");
let required = schema["required"]
.as_array()
.expect("schema required fields");
assert!(!required.contains(&Value::String("agent_id".to_string())));
assert!(!required.contains(&Value::String("agent_type".to_string())));
}
}
#[test]
fn subagent_context_fields_serialize_flat_and_omit_when_absent() {
let subagent = SubagentCommandInputFields::from(Some(&SubagentHookContext {
agent_id: "agent-1".to_string(),
agent_type: "worker".to_string(),
}));
let input = PreToolUseCommandInput {
session_id: "session-1".to_string(),
turn_id: "turn-1".to_string(),
agent_id: subagent.agent_id,
agent_type: subagent.agent_type,
transcript_path: NullableString::from_path(None),
cwd: "/tmp".to_string(),
hook_event_name: "PreToolUse".to_string(),
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
tool_input: json!({ "command": "echo hello" }),
tool_use_id: "tool-1".to_string(),
};
assert_eq!(
serde_json::to_value(input).expect("serialize subagent hook input"),
json!({
"session_id": "session-1",
"turn_id": "turn-1",
"agent_id": "agent-1",
"agent_type": "worker",
"transcript_path": null,
"cwd": "/tmp",
"hook_event_name": "PreToolUse",
"model": "gpt-test",
"permission_mode": "default",
"tool_name": "Bash",
"tool_input": { "command": "echo hello" },
"tool_use_id": "tool-1",
})
);
let root_input = PreToolUseCommandInput {
session_id: "session-1".to_string(),
turn_id: "turn-1".to_string(),
agent_id: None,
agent_type: None,
transcript_path: NullableString::from_path(None),
cwd: "/tmp".to_string(),
hook_event_name: "PreToolUse".to_string(),
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
tool_input: json!({ "command": "echo hello" }),
tool_use_id: "tool-1".to_string(),
};
let root_input = serde_json::to_value(root_input).expect("serialize root hook input");
assert_eq!(root_input.get("agent_id"), None);
assert_eq!(root_input.get("agent_type"), None);
}
}