Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 34 additions & 10 deletions codex-rs/core/src/hook_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use codex_hooks::SessionStartOutcome;
use codex_hooks::StartHookTarget;
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;
Expand Down Expand Up @@ -111,13 +112,11 @@ pub(crate) async fn run_pending_session_start_hooks(
codex_hooks::SessionStartSource::Startup
) =>
{
let agent_type = agent_role
.clone()
.unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string());
let context = subagent_hook_context(sess, agent_role);
StartHookTarget::SubagentStart {
turn_id: turn_context.sub_id.clone(),
agent_id: sess.thread_id().to_string(),
agent_type,
agent_id: context.agent_id,
agent_type: context.agent_type,
}
}
SessionSource::SubAgent(_) => return false,
Expand Down Expand Up @@ -168,6 +167,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,
Expand Down Expand Up @@ -228,6 +228,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,
Expand Down Expand Up @@ -269,6 +270,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,
Expand Down Expand Up @@ -303,9 +305,7 @@ pub(crate) async fn run_turn_stop_hooks(
parent_thread_id,
..
}) => {
let agent_type = agent_role
.clone()
.unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string());
let context = subagent_hook_context(sess, agent_role);
let agent_transcript_path = sess.hook_transcript_path().await;
let parent_transcript_path = match sess
.services
Expand All @@ -329,8 +329,8 @@ pub(crate) async fn run_turn_stop_hooks(
};
(
StopHookTarget::SubagentStop {
agent_id: sess.thread_id().to_string(),
agent_type,
agent_id: context.agent_id,
agent_type: context.agent_type,
agent_transcript_path,
},
parent_transcript_path,
Expand Down Expand Up @@ -369,6 +369,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,
Expand Down Expand Up @@ -407,6 +408,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,
Expand Down Expand Up @@ -502,6 +504,7 @@ pub(crate) async fn inspect_pending_input(
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,
Expand Down Expand Up @@ -728,6 +731,27 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String {
.to_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_context(sess: &Arc<Session>, agent_role: &Option<String>) -> SubagentHookContext {
SubagentHookContext {
agent_id: sess.thread_id().to_string(),
agent_type: agent_role
.clone()
.unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()),
}
}

fn compaction_trigger_label(value: CompactionTrigger) -> &'static str {
match value {
CompactionTrigger::Manual => "manual",
Expand Down
57 changes: 53 additions & 4 deletions codex-rs/core/tests/suite/subagent_notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,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)?;
Expand Down Expand Up @@ -212,6 +227,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": [{
Expand All @@ -230,6 +251,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())?;
Expand Down Expand Up @@ -504,7 +526,9 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<(

let test = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_subagent_lifecycle_hooks(home, &[], "worker") {
if let Err(error) =
write_subagent_lifecycle_hooks(home, /*stop_prompts*/ &[], "worker")
{
panic!("failed to write subagent hook fixture: {error}");
}
})
Expand Down Expand Up @@ -536,6 +560,29 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<(
Some(spawned_id.as_str())
);

let user_prompt_submit_inputs = wait_for_hook_log(
test.codex_home_path(),
"user_prompt_submit_hook_log.jsonl",
/*expected_len*/ 2,
)
.await?;
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 = wait_for_hook_log(
test.codex_home_path(),
"session_start_hook_log.jsonl",
Expand Down Expand Up @@ -627,9 +674,11 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<()

let test = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) =
write_subagent_lifecycle_hooks(home, &[SUBAGENT_STOP_CONTINUATION], "")
{
if let Err(error) = write_subagent_lifecycle_hooks(
home,
/*stop_prompts*/ &[SUBAGENT_STOP_CONTINUATION],
"",
) {
panic!("failed to write subagent hook fixture: {error}");
}
})
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions codex-rs/hooks/src/engine/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
7 changes: 7 additions & 0 deletions codex-rs/hooks/src/events/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading