From bd8ee83848e1111ed8d873711c30a858510e806d Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 17:30:36 +0000 Subject: [PATCH 01/10] Add SubagentStart hook --- codex-rs/analytics/src/events.rs | 1 + .../schema/json/ServerNotification.json | 1 + .../codex_app_server_protocol.schemas.json | 8 + .../codex_app_server_protocol.v2.schemas.json | 8 + .../v2/ConfigRequirementsReadResponse.json | 7 + .../json/v2/HookCompletedNotification.json | 1 + .../json/v2/HookStartedNotification.json | 1 + .../schema/json/v2/HooksListResponse.json | 1 + .../schema/json/v2/PluginReadResponse.json | 1 + .../schema/typescript/v2/HookEventName.ts | 2 +- .../typescript/v2/ManagedHooksRequirements.ts | 2 +- .../src/protocol/v2/config.rs | 3 + .../src/protocol/v2/hook.rs | 2 +- .../src/config/external_agent_config_tests.rs | 2 +- .../request_processors/config_processor.rs | 2 + codex-rs/config/src/hook_config.rs | 9 +- codex-rs/core/config.schema.json | 7 + codex-rs/core/src/hook_runtime.rs | 42 +++- codex-rs/core/src/session/tests.rs | 12 +- .../tests/suite/subagent_notifications.rs | 160 ++++++++++++++++ ...mission-request.command.output.schema.json | 1 + .../post-tool-use.command.output.schema.json | 1 + .../pre-tool-use.command.output.schema.json | 1 + .../session-start.command.output.schema.json | 1 + .../subagent-start.command.input.schema.json | 63 ++++++ .../subagent-start.command.output.schema.json | 64 +++++++ ...r-prompt-submit.command.output.schema.json | 1 + codex-rs/hooks/src/engine/dispatcher.rs | 3 +- codex-rs/hooks/src/engine/mod.rs | 1 + codex-rs/hooks/src/engine/output_parser.rs | 29 ++- codex-rs/hooks/src/engine/schema_loader.rs | 12 ++ codex-rs/hooks/src/events/common.rs | 1 + codex-rs/hooks/src/events/session_start.rs | 181 +++++++++++++++--- codex-rs/hooks/src/lib.rs | 8 +- codex-rs/hooks/src/schema.rs | 62 ++++++ codex-rs/protocol/src/protocol.rs | 1 + .../tui/src/bottom_pane/hooks_browser_view.rs | 2 + ...ser_view__tests__hooks_browser_events.snap | 1 + ...sts__hooks_browser_events_with_issues.snap | 1 + ...oks_browser_events_with_review_column.snap | 1 + ...s__hooks_popup_shows_list_diagnostics.snap | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + codex-rs/tui/src/history_cell/hook_cell.rs | 1 + 43 files changed, 667 insertions(+), 43 deletions(-) create mode 100644 codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json create mode 100644 codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d107a5a36712..f7c64e0e74e4 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -990,6 +990,7 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", HookEventName::Stop => "Stop", } } diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6af19f89e293..1695a54870c8 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1740,6 +1740,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 603b08d34370..a85aa77c0b0a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9564,6 +9564,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" @@ -10456,6 +10457,12 @@ }, "type": "array" }, + "SubagentStart": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" @@ -10483,6 +10490,7 @@ "PreToolUse", "SessionStart", "Stop", + "SubagentStart", "UserPromptSubmit" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 187650c9131f..eb9e46fd680e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6064,6 +6064,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" @@ -7005,6 +7006,12 @@ }, "type": "array" }, + "SubagentStart": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -7032,6 +7039,7 @@ "PreToolUse", "SessionStart", "Stop", + "SubagentStart", "UserPromptSubmit" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 41bd337decb1..577cd7bbc9b5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -261,6 +261,12 @@ }, "type": "array" }, + "SubagentStart": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -288,6 +294,7 @@ "PreToolUse", "SessionStart", "Stop", + "SubagentStart", "UserPromptSubmit" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index f63e6a5cfee0..164b4865fbc8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -14,6 +14,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index f8eeecfe4dea..924d522a8206 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -14,6 +14,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index f2a7c80cf00c..913962b60340 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -29,6 +29,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 0c9f897dc9f8..6d2a0b5d5653 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -46,6 +46,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index 91c2def7098d..b12f47169fa7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "stop"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts index cde0e4a50341..2b54af3c6dd2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; -export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, Stop: Array, }; +export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, SubagentStart: Array, Stop: Array, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index b46515d81142..0efa216940ae 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -421,6 +421,9 @@ pub struct ManagedHooksRequirements { #[serde(rename = "UserPromptSubmit")] #[ts(rename = "UserPromptSubmit")] pub user_prompt_submit: Vec, + #[serde(rename = "SubagentStart")] + #[ts(rename = "SubagentStart")] + pub subagent_start: Vec, #[serde(rename = "Stop")] #[ts(rename = "Stop")] pub stop: Vec, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs index 4a07bd495b46..8163e9f51aa3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -17,7 +17,7 @@ use ts_rs::TS; v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, Stop + PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, SubagentStart, Stop } ); diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index 8435f9baf532..07b09c5a1e43 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -422,7 +422,7 @@ async fn detect_repo_skips_hooks_when_only_unsupported_hooks_exist() { fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); fs::write( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), - r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","if":"Bash(rm *)","command":"echo blocked"}]}],"SubagentStart":[{"matcher":"worker","hooks":[{"type":"command","command":"echo started"}]}]}}"#, + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","if":"Bash(rm *)","command":"echo blocked"}]}],"UnsupportedEvent":[{"matcher":"worker","hooks":[{"type":"command","command":"echo started"}]}]}}"#, ) .expect("write hooks"); diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index b7f973b0763e..eb65331b4ccf 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -455,6 +455,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed post_compact, session_start, user_prompt_submit, + subagent_start, stop, } = hooks; @@ -468,6 +469,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed post_compact: map_hook_matcher_groups_to_api(post_compact), session_start: map_hook_matcher_groups_to_api(session_start), user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit), + subagent_start: map_hook_matcher_groups_to_api(subagent_start), stop: map_hook_matcher_groups_to_api(stop), } } diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 869761bdaa54..4d1329229fd2 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -45,6 +45,8 @@ pub struct HookEventsToml { pub session_start: Vec, #[serde(rename = "UserPromptSubmit", default)] pub user_prompt_submit: Vec, + #[serde(rename = "SubagentStart", default)] + pub subagent_start: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, } @@ -59,6 +61,7 @@ impl HookEventsToml { post_compact, session_start, user_prompt_submit, + subagent_start, stop, } = self; pre_tool_use.is_empty() @@ -68,6 +71,7 @@ impl HookEventsToml { && post_compact.is_empty() && session_start.is_empty() && user_prompt_submit.is_empty() + && subagent_start.is_empty() && stop.is_empty() } @@ -80,6 +84,7 @@ impl HookEventsToml { post_compact, session_start, user_prompt_submit, + subagent_start, stop, } = self; [ @@ -90,6 +95,7 @@ impl HookEventsToml { post_compact, session_start, user_prompt_submit, + subagent_start, stop, ] .into_iter() @@ -98,7 +104,7 @@ impl HookEventsToml { .sum() } - pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 8] { + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 9] { [ (HookEventName::PreToolUse, self.pre_tool_use), (HookEventName::PermissionRequest, self.permission_request), @@ -107,6 +113,7 @@ impl HookEventsToml { (HookEventName::PostCompact, self.post_compact), (HookEventName::SessionStart, self.session_start), (HookEventName::UserPromptSubmit, self.user_prompt_submit), + (HookEventName::SubagentStart, self.subagent_start), (HookEventName::Stop, self.stop), ] } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0387b2e4015e..8599708bf763 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1093,6 +1093,13 @@ }, "type": "array" }, + "SubagentStart": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "default": [], "items": { diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 78fc24833e84..8647c3ef5baa 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -13,6 +13,7 @@ use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; +use codex_hooks::SessionStartTarget; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; use codex_otel::HOOK_RUN_DURATION_METRIC; @@ -28,6 +29,8 @@ use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookRunSummary; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookStartedEvent; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde_json::Value; @@ -114,6 +117,25 @@ pub(crate) async fn run_pending_session_start_hooks( return false; }; + let target = match &turn_context.session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) + if matches!( + session_start_source, + codex_hooks::SessionStartSource::Startup + ) => + { + let metadata = subagent_hook_metadata(sess, agent_role); + SessionStartTarget::SubagentStart { + turn_id: turn_context.sub_id.clone(), + agent_id: metadata.agent_id, + agent_type: metadata.agent_type, + } + } + SessionSource::SubAgent(_) => return false, + _ => SessionStartTarget::SessionStart { + source: session_start_source, + }, + }; let request = codex_hooks::SessionStartRequest { session_id: sess.session_id().into(), #[allow(deprecated)] @@ -121,7 +143,7 @@ pub(crate) async fn run_pending_session_start_hooks( transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), - source: session_start_source, + target, }; let hooks = sess.hooks(); let preview_runs = hooks.preview_session_start(&request); @@ -555,6 +577,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", HookEventName::Stop => "Stop", }; let hook_source = match run.source { @@ -595,6 +618,23 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String { .to_string() } +struct SubagentHookMetadata { + agent_id: String, + agent_type: String, +} + +fn subagent_hook_metadata( + sess: &Arc, + agent_role: &Option, +) -> SubagentHookMetadata { + SubagentHookMetadata { + 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", diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 81012419cb5d..4f441ea08fd8 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -532,7 +532,9 @@ async fn preview_session_start_hooks( transcript_path: None, model: "gpt-5.2".to_string(), permission_mode: "default".to_string(), - source: codex_hooks::SessionStartSource::Startup, + target: codex_hooks::SessionStartTarget::SessionStart { + source: codex_hooks::SessionStartSource::Startup, + }, }), ) } @@ -1304,7 +1306,9 @@ async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> { transcript_path: None, model: "gpt-5.2".to_string(), permission_mode: "default".to_string(), - source: codex_hooks::SessionStartSource::Startup, + target: codex_hooks::SessionStartTarget::SessionStart { + source: codex_hooks::SessionStartSource::Startup, + }, }; assert!(session.hooks().preview_session_start(&request).is_empty()); @@ -1409,7 +1413,9 @@ async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> { transcript_path: None, model: "gpt-5.2".to_string(), permission_mode: "default".to_string(), - source: codex_hooks::SessionStartSource::Startup, + target: codex_hooks::SessionStartTarget::SessionStart { + source: codex_hooks::SessionStartSource::Startup, + }, }; assert!(session.hooks().preview_session_start(&request).is_empty()); diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 3a0c37acc70a..a91149b46ac2 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -37,6 +37,7 @@ const REQUESTED_MODEL: &str = "gpt-5.4"; const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; const ROLE_MODEL: &str = "gpt-5.4"; const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High; +const SUBAGENT_START_CONTEXT: &str = "subagent start context reaches child"; fn body_contains(req: &wiremock::Request, text: &str) -> bool { let is_zstd = req @@ -110,6 +111,75 @@ fn write_home_skill(codex_home: &Path, dir: &str, name: &str, description: &str) Ok(()) } +fn write_subagent_start_hooks(home: &Path) -> Result<()> { + let session_start_script_path = home.join("session_start_hook.py"); + let session_start_log_path = home.join("session_start_hook_log.jsonl"); + let session_start_script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{session_start_log_path}") +payload = json.load(sys.stdin) +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +"#, + session_start_log_path = session_start_log_path.display(), + ); + + let start_script_path = home.join("subagent_start_hook.py"); + let start_log_path = home.join("subagent_start_hook_log.jsonl"); + let start_script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{start_log_path}") +payload = json.load(sys.stdin) +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +print(json.dumps({{"hookSpecificOutput": {{"hookEventName": "SubagentStart", "additionalContext": {SUBAGENT_START_CONTEXT:?}}}}})) +"#, + start_log_path = start_log_path.display(), + ); + + let hooks = serde_json::json!({ + "hooks": { + "SessionStart": [{ + "matcher": "startup", + "hooks": [{ + "type": "command", + "command": format!("python3 {}", session_start_script_path.display()), + }] + }], + "SubagentStart": [{ + "matcher": "worker", + "hooks": [{ + "type": "command", + "command": format!("python3 {}", start_script_path.display()), + }] + }] + } + }); + + fs::write(&session_start_script_path, session_start_script)?; + fs::write(&start_script_path, start_script)?; + fs::write(home.join("hooks.json"), hooks.to_string())?; + Ok(()) +} + +fn read_hook_log(home: &Path, filename: &str) -> Result> { + let path = home.join(filename); + if !path.exists() { + return Ok(Vec::new()); + } + fs::read_to_string(path)? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).map_err(Into::into)) + .collect() +} + async fn wait_for_spawned_thread_id(test: &TestCodex) -> Result { let deadline = Instant::now() + Duration::from_secs(2); loop { @@ -279,6 +349,96 @@ async fn spawn_child_and_capture_snapshot( .await) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn subagent_start_replaces_session_start_and_injects_context() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let spawn_args = serde_json::to_string(&json!({ + "message": CHILD_PROMPT, + "task_name": "child", + "agent_type": "worker", + }))?; + + mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), + sse(vec![ + ev_response_created("resp-turn1-1"), + ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_completed("resp-turn1-1"), + ]), + ) + .await; + + let child_request_log = mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, CHILD_PROMPT) + && body_contains(req, SUBAGENT_START_CONTEXT) + && !body_contains(req, "") + && !body_contains(req, SPAWN_CALL_ID) + }, + sse(vec![ + ev_response_created("resp-child-1"), + ev_assistant_message("msg-child-1", "child done"), + ev_completed("resp-child-1"), + ]), + ) + .await; + + let _turn1_followup = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), + sse(vec![ + ev_response_created("resp-turn1-2"), + ev_assistant_message("msg-turn1-2", "parent done"), + ev_completed("resp-turn1-2"), + ]), + ) + .await; + + let test = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_subagent_start_hooks(home) { + panic!("failed to write subagent hook fixture: {error}"); + } + }) + .with_config(|config| { + core_test_support::hooks::trust_discovered_hooks(config); + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + + test.submit_turn(TURN_1_PROMPT).await?; + let child_requests = wait_for_requests(&child_request_log).await?; + assert_eq!(child_requests.len(), 1); + + let start_inputs = read_hook_log(test.codex_home_path(), "subagent_start_hook_log.jsonl")?; + assert_eq!(start_inputs.len(), 1); + assert_eq!(start_inputs[0]["agent_type"].as_str(), Some("worker")); + let spawned_id = wait_for_spawned_thread_id(&test).await?; + assert_eq!( + start_inputs[0]["agent_id"].as_str(), + Some(spawned_id.as_str()) + ); + + let session_start_inputs = + read_hook_log(test.codex_home_path(), "session_start_hook_log.jsonl")?; + assert_eq!(session_start_inputs.len(), 1); + assert_eq!(session_start_inputs[0]["source"].as_str(), Some("startup")); + assert_ne!( + session_start_inputs[0]["session_id"].as_str(), + Some(spawned_id.as_str()) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn subagent_notification_is_included_without_wait() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json index c89f80a3b09e..44e775a03d73 100644 --- a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json @@ -11,6 +11,7 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json index 2b64c0ad0049..b762c549c5b7 100644 --- a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json @@ -17,6 +17,7 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json index 3ab17b9f5c18..c4c41324481c 100644 --- a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -11,6 +11,7 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json index 4d6ba033fed6..d8a0f4648246 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -11,6 +11,7 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json b/codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json new file mode 100644 index 000000000000..2f6edd60387a --- /dev/null +++ b/codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "SubagentStart", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "agent_id", + "agent_type", + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "transcript_path", + "turn_id" + ], + "title": "subagent-start.command.input", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json b/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json new file mode 100644 index 000000000000..16140f326b42 --- /dev/null +++ b/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "HookEventNameWire": { + "enum": [ + "PreToolUse", + "PermissionRequest", + "PostToolUse", + "PreCompact", + "PostCompact", + "SessionStart", + "UserPromptSubmit", + "SubagentStart", + "Stop" + ], + "type": "string" + }, + "SessionStartHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "additionalContext": { + "default": null, + "type": "string" + }, + "hookEventName": { + "$ref": "#/definitions/HookEventNameWire" + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/SessionStartHookSpecificOutputWire" + } + ], + "default": null + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "subagent-start.command.output", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json index 6baceb149f40..d4d66d29767a 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json @@ -17,6 +17,7 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 7243fc6eab42..51c6d6672e03 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -49,6 +49,7 @@ pub(crate) fn select_handlers_for_matcher_inputs( | HookEventName::PermissionRequest | HookEventName::PostToolUse | HookEventName::SessionStart + | HookEventName::SubagentStart | HookEventName::PreCompact | HookEventName::PostCompact => { if matcher_inputs.is_empty() { @@ -139,7 +140,7 @@ pub(crate) fn completed_summary( fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { - HookEventName::SessionStart => HookScope::Thread, + HookEventName::SessionStart | HookEventName::SubagentStart => HookScope::Thread, HookEventName::PreToolUse | HookEventName::PermissionRequest | HookEventName::PostToolUse diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 8a4a11a5d41f..7cbbd7a02713 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -70,6 +70,7 @@ impl ConfiguredHandler { codex_protocol::protocol::HookEventName::PostCompact => "post-compact", codex_protocol::protocol::HookEventName::SessionStart => "session-start", codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit", + codex_protocol::protocol::HookEventName::SubagentStart => "subagent-start", codex_protocol::protocol::HookEventName::Stop => "stop", } } diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs index 48ad1aff2dd8..1ea44c0903cc 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -86,17 +86,34 @@ use crate::schema::PreToolUseDecisionWire; use crate::schema::PreToolUsePermissionDecisionWire; use crate::schema::SessionStartCommandOutputWire; use crate::schema::StopCommandOutputWire; +use crate::schema::SubagentStartCommandOutputWire; use crate::schema::UserPromptSubmitCommandOutputWire; pub(crate) fn parse_session_start(stdout: &str) -> Option { let wire: SessionStartCommandOutputWire = parse_json(stdout)?; - let additional_context = wire - .hook_specific_output - .and_then(|output| output.additional_context); - Some(SessionStartOutput { - universal: UniversalOutput::from(wire.universal), + Some(session_start_output( + wire.universal, + wire.hook_specific_output, + )) +} + +pub(crate) fn parse_subagent_start(stdout: &str) -> Option { + let wire: SubagentStartCommandOutputWire = parse_json(stdout)?; + Some(session_start_output( + wire.universal, + wire.hook_specific_output, + )) +} + +fn session_start_output( + universal: HookUniversalOutputWire, + hook_specific_output: Option, +) -> SessionStartOutput { + let additional_context = hook_specific_output.and_then(|output| output.additional_context); + SessionStartOutput { + universal: UniversalOutput::from(universal), additional_context, - }) + } } pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option { diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index 704edea4bac6..2b1994f28d7a 100644 --- a/codex-rs/hooks/src/engine/schema_loader.rs +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -16,6 +16,8 @@ pub(crate) struct GeneratedHookSchemas { pub pre_compact_command_output: Value, pub session_start_command_input: Value, pub session_start_command_output: Value, + pub subagent_start_command_input: Value, + pub subagent_start_command_output: Value, pub user_prompt_submit_command_input: Value, pub user_prompt_submit_command_output: Value, pub stop_command_input: Value, @@ -73,6 +75,14 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas { "session-start.command.output", include_str!("../../schema/generated/session-start.command.output.schema.json"), ), + subagent_start_command_input: parse_json_schema( + "subagent-start.command.input", + include_str!("../../schema/generated/subagent-start.command.input.schema.json"), + ), + subagent_start_command_output: parse_json_schema( + "subagent-start.command.output", + include_str!("../../schema/generated/subagent-start.command.output.schema.json"), + ), user_prompt_submit_command_input: parse_json_schema( "user-prompt-submit.command.input", include_str!("../../schema/generated/user-prompt-submit.command.input.schema.json"), @@ -118,6 +128,8 @@ mod tests { assert_eq!(schemas.pre_compact_command_output["type"], "object"); assert_eq!(schemas.session_start_command_input["type"], "object"); assert_eq!(schemas.session_start_command_output["type"], "object"); + assert_eq!(schemas.subagent_start_command_input["type"], "object"); + assert_eq!(schemas.subagent_start_command_output["type"], "object"); assert_eq!(schemas.user_prompt_submit_command_input["type"], "object"); assert_eq!(schemas.user_prompt_submit_command_output["type"], "object"); assert_eq!(schemas.stop_command_input["type"], "object"); diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index e79a139b79f7..f96de580201a 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -104,6 +104,7 @@ pub(crate) fn matcher_pattern_for_event( | HookEventName::PermissionRequest | HookEventName::PostToolUse | HookEventName::SessionStart + | HookEventName::SubagentStart | HookEventName::PreCompact | HookEventName::PostCompact => matcher, HookEventName::UserPromptSubmit | HookEventName::Stop => None, diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 88bd9c00a038..fdf550b69010 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -15,7 +15,9 @@ 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::SessionStartCommandInput; +use crate::schema::SubagentStartCommandInput; #[derive(Debug, Clone, Copy)] pub enum SessionStartSource { @@ -41,7 +43,35 @@ pub struct SessionStartRequest { pub transcript_path: Option, pub model: String, pub permission_mode: String, - pub source: SessionStartSource, + pub target: SessionStartTarget, +} + +#[derive(Debug, Clone)] +pub enum SessionStartTarget { + SessionStart { + source: SessionStartSource, + }, + SubagentStart { + turn_id: String, + agent_id: String, + agent_type: String, + }, +} + +impl SessionStartTarget { + fn event_name(&self) -> HookEventName { + match self { + Self::SessionStart { .. } => HookEventName::SessionStart, + Self::SubagentStart { .. } => HookEventName::SubagentStart, + } + } + + fn matcher_input(&self) -> &str { + match self { + Self::SessionStart { source } => source.as_str(), + Self::SubagentStart { agent_type, .. } => agent_type.as_str(), + } + } } #[derive(Debug)] @@ -65,8 +95,8 @@ pub(crate) fn preview( ) -> Vec { dispatcher::select_handlers( handlers, - HookEventName::SessionStart, - Some(request.source.as_str()), + request.target.event_name(), + Some(request.target.matcher_input()), ) .into_iter() .map(|handler| dispatcher::running_summary(&handler)) @@ -81,8 +111,8 @@ pub(crate) async fn run( ) -> SessionStartOutcome { let matched = dispatcher::select_handlers( handlers, - HookEventName::SessionStart, - Some(request.source.as_str()), + request.target.event_name(), + Some(request.target.matcher_input()), ); if matched.is_empty() { return SessionStartOutcome { @@ -93,21 +123,76 @@ pub(crate) async fn run( }; } - let input_json = match serde_json::to_string(&SessionStartCommandInput::new( - request.session_id.to_string(), - request.transcript_path.clone(), - request.cwd.display().to_string(), - request.model.clone(), - request.permission_mode.clone(), - request.source.as_str().to_string(), - )) { - Ok(input_json) => input_json, - Err(error) => { - return serialization_failure_outcome(common::serialization_failure_hook_events( - matched, - turn_id, - format!("failed to serialize session start hook input: {error}"), - )); + let (input_json, turn_id, parse_completed) = match request.target { + SessionStartTarget::SessionStart { source } => { + match serde_json::to_string(&SessionStartCommandInput::new( + request.session_id.to_string(), + request.transcript_path.clone(), + request.cwd.display().to_string(), + request.model.clone(), + request.permission_mode.clone(), + source.as_str().to_string(), + )) { + Ok(input_json) => ( + input_json, + turn_id, + parse_completed + as fn( + &ConfiguredHandler, + CommandRunResult, + Option, + ) + -> dispatcher::ParsedHandler, + ), + Err(error) => { + return serialization_failure_outcome( + common::serialization_failure_hook_events( + matched, + turn_id, + format!("failed to serialize session start hook input: {error}"), + ), + ); + } + } + } + SessionStartTarget::SubagentStart { + turn_id, + agent_id, + agent_type, + } => { + let input = SubagentStartCommandInput { + session_id: request.session_id.to_string(), + turn_id: turn_id.clone(), + transcript_path: NullableString::from_path(request.transcript_path.clone()), + cwd: request.cwd.display().to_string(), + hook_event_name: "SubagentStart".to_string(), + model: request.model.clone(), + permission_mode: request.permission_mode.clone(), + agent_id, + agent_type, + }; + match serde_json::to_string(&input) { + Ok(input_json) => ( + input_json, + Some(turn_id), + parse_subagent_start_completed + as fn( + &ConfiguredHandler, + CommandRunResult, + Option, + ) + -> dispatcher::ParsedHandler, + ), + Err(error) => { + return serialization_failure_outcome( + common::serialization_failure_hook_events( + matched, + Some(turn_id), + format!("failed to serialize subagent start hook input: {error}"), + ), + ); + } + } } }; @@ -143,6 +228,51 @@ fn parse_completed( handler: &ConfiguredHandler, run_result: CommandRunResult, turn_id: Option, +) -> dispatcher::ParsedHandler { + parse_start_completed( + handler, + run_result, + turn_id, + "session start", + output_parser::parse_session_start, + StartHookSemantics { + allow_plain_text_context: true, + allow_stop: true, + }, + ) +} + +fn parse_subagent_start_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler { + parse_start_completed( + handler, + run_result, + turn_id, + "subagent start", + output_parser::parse_subagent_start, + StartHookSemantics { + allow_plain_text_context: false, + allow_stop: false, + }, + ) +} + +#[derive(Clone, Copy)] +struct StartHookSemantics { + allow_plain_text_context: bool, + allow_stop: bool, +} + +fn parse_start_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, + hook_label: &str, + parse_output: fn(&str) -> Option, + semantics: StartHookSemantics, ) -> dispatcher::ParsedHandler { let mut entries = Vec::new(); let mut status = HookRunStatus::Completed; @@ -162,8 +292,7 @@ fn parse_completed( Some(0) => { let trimmed_stdout = run_result.stdout.trim(); if trimmed_stdout.is_empty() { - } else if let Some(parsed) = output_parser::parse_session_start(&run_result.stdout) - { + } else if let Some(parsed) = parse_output(&run_result.stdout) { if let Some(system_message) = parsed.universal.system_message { entries.push(HookOutputEntry { kind: HookOutputEntryKind::Warning, @@ -178,7 +307,7 @@ fn parse_completed( ); } let _ = parsed.universal.suppress_output; - if !parsed.universal.continue_processing { + if semantics.allow_stop && !parsed.universal.continue_processing { status = HookRunStatus::Stopped; should_stop = true; stop_reason = parsed.universal.stop_reason.clone(); @@ -190,11 +319,13 @@ fn parse_completed( } } // Preserve plain-text context support without treating malformed JSON as context. - } else if output_parser::looks_like_json(&run_result.stdout) { + } else if !semantics.allow_plain_text_context + || output_parser::looks_like_json(&run_result.stdout) + { status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: "hook returned invalid session start JSON output".to_string(), + text: format!("hook returned invalid {hook_label} JSON output"), }); } else { let additional_context = trimmed_stdout.to_string(); diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index ec59368d3049..93bbe72ba544 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -15,7 +15,7 @@ pub use declarations::PluginHookDeclaration; pub use declarations::plugin_hook_declarations; pub use engine::HookListEntry; /// Hook event names as they appear in hooks JSON and config files. -pub const HOOK_EVENT_NAMES: [&str; 8] = [ +pub const HOOK_EVENT_NAMES: [&str; 9] = [ "PreToolUse", "PermissionRequest", "PostToolUse", @@ -23,6 +23,7 @@ pub const HOOK_EVENT_NAMES: [&str; 8] = [ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", "Stop", ]; @@ -31,13 +32,14 @@ pub const HOOK_EVENT_NAMES: [&str; 8] = [ /// Other events can appear in hooks JSON, but Codex ignores their matcher /// fields because those events do not dispatch against a tool, compaction /// trigger, or session-start source. -pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 6] = [ +pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 7] = [ "PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact", "SessionStart", + "SubagentStart", ]; pub use events::compact::PostCompactRequest; @@ -54,6 +56,7 @@ pub use events::pre_tool_use::PreToolUseRequest; pub use events::session_start::SessionStartOutcome; pub use events::session_start::SessionStartRequest; pub use events::session_start::SessionStartSource; +pub use events::session_start::SessionStartTarget; pub use events::stop::StopOutcome; pub use events::stop::StopRequest; pub use events::user_prompt_submit::UserPromptSubmitOutcome; @@ -83,6 +86,7 @@ pub fn hook_event_key_label(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "post_compact", HookEventName::SessionStart => "session_start", HookEventName::UserPromptSubmit => "user_prompt_submit", + HookEventName::SubagentStart => "subagent_start", HookEventName::Stop => "stop", } } diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index d4f408d8c35b..96d5f790be16 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -27,6 +27,8 @@ const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.js const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json"; const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json"; const USER_PROMPT_SUBMIT_OUTPUT_FIXTURE: &str = "user-prompt-submit.command.output.schema.json"; +const SUBAGENT_START_INPUT_FIXTURE: &str = "subagent-start.command.input.schema.json"; +const SUBAGENT_START_OUTPUT_FIXTURE: &str = "subagent-start.command.output.schema.json"; const STOP_INPUT_FIXTURE: &str = "stop.command.input.schema.json"; const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json"; @@ -87,6 +89,8 @@ pub(crate) enum HookEventNameWire { SessionStart, #[serde(rename = "UserPromptSubmit")] UserPromptSubmit, + #[serde(rename = "SubagentStart")] + SubagentStart, #[serde(rename = "Stop")] Stop, } @@ -345,6 +349,17 @@ pub(crate) struct SessionStartHookSpecificOutputWire { pub additional_context: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[schemars(rename = "subagent-start.command.output")] +pub(crate) struct SubagentStartCommandOutputWire { + #[serde(flatten)] + pub universal: HookUniversalOutputWire, + #[serde(default)] + pub hook_specific_output: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -427,6 +442,24 @@ impl SessionStartCommandInput { } } +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "subagent-start.command.input")] +pub(crate) struct SubagentStartCommandInput { + 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 = "subagent_start_hook_event_name_schema")] + pub hook_event_name: String, + pub model: String, + #[schemars(schema_with = "permission_mode_schema")] + pub permission_mode: String, + pub agent_id: String, + pub agent_type: String, +} + #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] #[schemars(rename = "user-prompt-submit.command.input")] @@ -522,6 +555,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { &generated_dir.join(USER_PROMPT_SUBMIT_OUTPUT_FIXTURE), schema_json::()?, )?; + write_schema( + &generated_dir.join(SUBAGENT_START_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(SUBAGENT_START_OUTPUT_FIXTURE), + schema_json::()?, + )?; write_schema( &generated_dir.join(STOP_INPUT_FIXTURE), schema_json::()?, @@ -613,6 +654,10 @@ fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Sche string_const_schema("UserPromptSubmit") } +fn subagent_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("SubagentStart") +} + fn stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("Stop") } @@ -683,7 +728,10 @@ mod tests { use super::SESSION_START_OUTPUT_FIXTURE; use super::STOP_INPUT_FIXTURE; use super::STOP_OUTPUT_FIXTURE; + use super::SUBAGENT_START_INPUT_FIXTURE; + use super::SUBAGENT_START_OUTPUT_FIXTURE; use super::StopCommandInput; + use super::SubagentStartCommandInput; use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE; use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE; use super::UserPromptSubmitCommandInput; @@ -737,6 +785,12 @@ mod tests { USER_PROMPT_SUBMIT_OUTPUT_FIXTURE => { include_str!("../schema/generated/user-prompt-submit.command.output.schema.json") } + SUBAGENT_START_INPUT_FIXTURE => { + include_str!("../schema/generated/subagent-start.command.input.schema.json") + } + SUBAGENT_START_OUTPUT_FIXTURE => { + include_str!("../schema/generated/subagent-start.command.output.schema.json") + } STOP_INPUT_FIXTURE => { include_str!("../schema/generated/stop.command.input.schema.json") } @@ -772,6 +826,8 @@ mod tests { SESSION_START_OUTPUT_FIXTURE, USER_PROMPT_SUBMIT_INPUT_FIXTURE, USER_PROMPT_SUBMIT_OUTPUT_FIXTURE, + SUBAGENT_START_INPUT_FIXTURE, + SUBAGENT_START_OUTPUT_FIXTURE, STOP_INPUT_FIXTURE, STOP_OUTPUT_FIXTURE, ] { @@ -814,6 +870,11 @@ mod tests { .expect("serialize user prompt submit input schema"), ) .expect("parse user prompt submit input schema"); + let subagent_start: Value = serde_json::from_slice( + &schema_json::() + .expect("serialize subagent start input schema"), + ) + .expect("parse subagent start input schema"); let stop: Value = serde_json::from_slice( &schema_json::().expect("serialize stop input schema"), ) @@ -826,6 +887,7 @@ mod tests { &pre_compact, &post_compact, &user_prompt_submit, + &subagent_start, &stop, ] { assert_eq!(schema["properties"]["turn_id"]["type"], "string"); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 91fd02d8583e..36fca9fbab07 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1458,6 +1458,7 @@ pub enum HookEventName { PostCompact, SessionStart, UserPromptSubmit, + SubagentStart, Stop, } diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 462e9b286e40..1202265aa2ec 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -736,6 +736,7 @@ fn event_label(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", HookEventName::Stop => "Stop", } } @@ -749,6 +750,7 @@ fn event_description(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "After context compaction", HookEventName::SessionStart => "When a new session starts", HookEventName::UserPromptSubmit => "When the user submits a prompt", + HookEventName::SubagentStart => "When a subagent is created", HookEventName::Stop => "Right before Codex ends its turn", } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap index 34c1d8ceec4b..836a65c3f1a6 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap @@ -14,6 +14,7 @@ expression: "render_lines(&view, 112)" PostCompact 0 0 After context compaction SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt + SubagentStart 0 0 When a subagent is created Stop 0 0 Right before Codex ends its turn Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap index 511f173813b5..fabd9c1707bd 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap @@ -18,6 +18,7 @@ expression: "render_lines(&view, 112)" PostCompact 0 0 After context compaction SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt + SubagentStart 0 0 When a subagent is created Stop 0 0 Right before Codex ends its turn Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap index 07a39ebc9ca8..dd4e5e33b584 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap @@ -16,6 +16,7 @@ expression: "render_lines(&view, 112)" PostCompact 0 0 0 After context compaction SessionStart 0 0 0 When a new session starts UserPromptSubmit 0 0 0 When the user submits a prompt + SubagentStart 0 0 0 When a subagent is created Stop 0 0 0 Right before Codex ends its turn Press t to trust all; enter to review hooks; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap index 5224197d2f37..9a532f563db7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap @@ -17,6 +17,7 @@ expression: popup PostCompact 0 0 After context compaction SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt + SubagentStart 0 0 When a subagent is created Stop 0 0 Right before Codex ends its turn Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 28ed69139b9f..f6a6001bdb97 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1572,6 +1572,7 @@ fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'s codex_app_server_protocol::HookEventName::PostCompact => "PostCompact", codex_app_server_protocol::HookEventName::SessionStart => "SessionStart", codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", + codex_app_server_protocol::HookEventName::SubagentStart => "SubagentStart", codex_app_server_protocol::HookEventName::Stop => "Stop", } } diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index e78ce27fb0f7..824fdc1de837 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -720,6 +720,7 @@ fn hook_event_label(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", HookEventName::Stop => "Stop", } } From 48110f91dcd319e67bcef39b4f87a6893edf8634 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 18:01:57 +0000 Subject: [PATCH 02/10] Add SubagentStop hook --- codex-rs/analytics/src/events.rs | 1 + .../schema/json/ServerNotification.json | 1 + .../codex_app_server_protocol.schemas.json | 8 + .../codex_app_server_protocol.v2.schemas.json | 8 + .../v2/ConfigRequirementsReadResponse.json | 7 + .../json/v2/HookCompletedNotification.json | 1 + .../json/v2/HookStartedNotification.json | 1 + .../schema/json/v2/HooksListResponse.json | 1 + .../schema/json/v2/PluginReadResponse.json | 1 + .../schema/typescript/v2/HookEventName.ts | 2 +- .../typescript/v2/ManagedHooksRequirements.ts | 2 +- .../src/protocol/v2/config.rs | 3 + .../src/protocol/v2/hook.rs | 2 +- .../request_processors/config_processor.rs | 2 + codex-rs/config/src/hook_config.rs | 9 +- codex-rs/core/config.schema.json | 7 + codex-rs/core/src/hook_runtime.rs | 70 ++++ codex-rs/core/src/session/turn.rs | 41 +-- .../tests/suite/subagent_notifications.rs | 306 +++++++++++++++++- ...mission-request.command.output.schema.json | 1 + .../post-tool-use.command.output.schema.json | 1 + .../pre-tool-use.command.output.schema.json | 1 + .../session-start.command.output.schema.json | 1 + .../subagent-start.command.output.schema.json | 1 + .../subagent-stop.command.input.schema.json | 75 +++++ .../subagent-stop.command.output.schema.json | 45 +++ ...r-prompt-submit.command.output.schema.json | 1 + codex-rs/hooks/src/engine/dispatcher.rs | 2 + codex-rs/hooks/src/engine/mod.rs | 1 + codex-rs/hooks/src/engine/output_parser.rs | 39 ++- codex-rs/hooks/src/engine/schema_loader.rs | 12 + codex-rs/hooks/src/events/common.rs | 1 + codex-rs/hooks/src/events/stop.rs | 197 +++++++++-- codex-rs/hooks/src/lib.rs | 8 +- codex-rs/hooks/src/schema.rs | 69 ++++ codex-rs/protocol/src/protocol.rs | 1 + .../tui/src/bottom_pane/hooks_browser_view.rs | 2 + ...ser_view__tests__hooks_browser_events.snap | 1 + ...sts__hooks_browser_events_with_issues.snap | 1 + ...oks_browser_events_with_review_column.snap | 1 + ...s__hooks_popup_shows_list_diagnostics.snap | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + codex-rs/tui/src/history_cell/hook_cell.rs | 1 + 43 files changed, 853 insertions(+), 84 deletions(-) create mode 100644 codex-rs/hooks/schema/generated/subagent-stop.command.input.schema.json create mode 100644 codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index f7c64e0e74e4..87cb165ac218 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -991,6 +991,7 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str { HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::SubagentStart => "SubagentStart", + HookEventName::SubagentStop => "SubagentStop", HookEventName::Stop => "Stop", } } diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 1695a54870c8..5da36e1d24e3 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1741,6 +1741,7 @@ "sessionStart", "userPromptSubmit", "subagentStart", + "subagentStop", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a85aa77c0b0a..67dea3f00b6f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9565,6 +9565,7 @@ "sessionStart", "userPromptSubmit", "subagentStart", + "subagentStop", "stop" ], "type": "string" @@ -10463,6 +10464,12 @@ }, "type": "array" }, + "SubagentStop": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" @@ -10491,6 +10498,7 @@ "SessionStart", "Stop", "SubagentStart", + "SubagentStop", "UserPromptSubmit" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index eb9e46fd680e..2517ad59ba1b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6065,6 +6065,7 @@ "sessionStart", "userPromptSubmit", "subagentStart", + "subagentStop", "stop" ], "type": "string" @@ -7012,6 +7013,12 @@ }, "type": "array" }, + "SubagentStop": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -7040,6 +7047,7 @@ "SessionStart", "Stop", "SubagentStart", + "SubagentStop", "UserPromptSubmit" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 577cd7bbc9b5..9c86bb4572ba 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -267,6 +267,12 @@ }, "type": "array" }, + "SubagentStop": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -295,6 +301,7 @@ "SessionStart", "Stop", "SubagentStart", + "SubagentStop", "UserPromptSubmit" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index 164b4865fbc8..088da47484c5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -15,6 +15,7 @@ "sessionStart", "userPromptSubmit", "subagentStart", + "subagentStop", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 924d522a8206..38395cded97e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -15,6 +15,7 @@ "sessionStart", "userPromptSubmit", "subagentStart", + "subagentStop", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index 913962b60340..c58a5c767d8b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -30,6 +30,7 @@ "sessionStart", "userPromptSubmit", "subagentStart", + "subagentStop", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 6d2a0b5d5653..c9fe43c34f9f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -47,6 +47,7 @@ "sessionStart", "userPromptSubmit", "subagentStart", + "subagentStop", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index b12f47169fa7..477476289db7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "stop"; +export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts index 2b54af3c6dd2..1143bd017f21 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; -export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, SubagentStart: Array, Stop: Array, }; +export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, SubagentStart: Array, SubagentStop: Array, Stop: Array, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 0efa216940ae..8bbd375d6cf3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -424,6 +424,9 @@ pub struct ManagedHooksRequirements { #[serde(rename = "SubagentStart")] #[ts(rename = "SubagentStart")] pub subagent_start: Vec, + #[serde(rename = "SubagentStop")] + #[ts(rename = "SubagentStop")] + pub subagent_stop: Vec, #[serde(rename = "Stop")] #[ts(rename = "Stop")] pub stop: Vec, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs index 8163e9f51aa3..6e6c1ef7d411 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -17,7 +17,7 @@ use ts_rs::TS; v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, SubagentStart, Stop + PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, SubagentStart, SubagentStop, Stop } ); diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index eb65331b4ccf..0a53980d23e2 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -456,6 +456,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed session_start, user_prompt_submit, subagent_start, + subagent_stop, stop, } = hooks; @@ -470,6 +471,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed session_start: map_hook_matcher_groups_to_api(session_start), user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit), subagent_start: map_hook_matcher_groups_to_api(subagent_start), + subagent_stop: map_hook_matcher_groups_to_api(subagent_stop), stop: map_hook_matcher_groups_to_api(stop), } } diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 4d1329229fd2..eee66896aff2 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -47,6 +47,8 @@ pub struct HookEventsToml { pub user_prompt_submit: Vec, #[serde(rename = "SubagentStart", default)] pub subagent_start: Vec, + #[serde(rename = "SubagentStop", default)] + pub subagent_stop: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, } @@ -62,6 +64,7 @@ impl HookEventsToml { session_start, user_prompt_submit, subagent_start, + subagent_stop, stop, } = self; pre_tool_use.is_empty() @@ -72,6 +75,7 @@ impl HookEventsToml { && session_start.is_empty() && user_prompt_submit.is_empty() && subagent_start.is_empty() + && subagent_stop.is_empty() && stop.is_empty() } @@ -85,6 +89,7 @@ impl HookEventsToml { session_start, user_prompt_submit, subagent_start, + subagent_stop, stop, } = self; [ @@ -96,6 +101,7 @@ impl HookEventsToml { session_start, user_prompt_submit, subagent_start, + subagent_stop, stop, ] .into_iter() @@ -104,7 +110,7 @@ impl HookEventsToml { .sum() } - pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 9] { + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 10] { [ (HookEventName::PreToolUse, self.pre_tool_use), (HookEventName::PermissionRequest, self.permission_request), @@ -114,6 +120,7 @@ impl HookEventsToml { (HookEventName::SessionStart, self.session_start), (HookEventName::UserPromptSubmit, self.user_prompt_submit), (HookEventName::SubagentStart, self.subagent_start), + (HookEventName::SubagentStop, self.subagent_stop), (HookEventName::Stop, self.stop), ] } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8599708bf763..7ef8b58be166 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1100,6 +1100,13 @@ }, "type": "array" }, + "SubagentStop": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "default": [], "items": { diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 8647c3ef5baa..6acae02578a5 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -14,6 +14,8 @@ use codex_hooks::PreToolUseOutcome; use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; use codex_hooks::SessionStartTarget; +use codex_hooks::StopHookTarget; +use codex_hooks::StopOutcome; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; use codex_otel::HOOK_RUN_DURATION_METRIC; @@ -32,6 +34,7 @@ use codex_protocol::protocol::HookStartedEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; +use codex_thread_store::ReadThreadParams; use serde_json::Value; use crate::context::ContextualUserFragment; @@ -294,6 +297,72 @@ pub(crate) async fn run_post_tool_use_hooks( outcome } +pub(crate) async fn run_turn_stop_hooks( + sess: &Arc, + turn_context: &Arc, + stop_hook_active: bool, + last_assistant_message: Option, +) -> StopOutcome { + let (target, transcript_path) = match &turn_context.session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + agent_role, + parent_thread_id, + .. + }) => { + let metadata = subagent_hook_metadata(sess, agent_role); + let agent_transcript_path = sess.hook_transcript_path().await; + let parent_transcript_path = match sess + .services + .thread_store + .read_thread(ReadThreadParams { + thread_id: *parent_thread_id, + include_archived: true, + include_history: false, + }) + .await + { + Ok(thread) => thread.rollout_path, + Err(error) => { + tracing::warn!( + parent_thread_id = %parent_thread_id, + error = %error, + "failed to resolve parent transcript path for subagent hook" + ); + None + } + }; + ( + StopHookTarget::SubagentStop { + agent_id: metadata.agent_id, + agent_type: metadata.agent_type, + agent_transcript_path, + }, + parent_transcript_path, + ) + } + SessionSource::SubAgent(_) => return StopOutcome::default(), + _ => (StopHookTarget::Stop, sess.hook_transcript_path().await), + }; + let request = codex_hooks::StopRequest { + session_id: sess.session_id().into(), + turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] + cwd: turn_context.cwd.clone(), + transcript_path, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + stop_hook_active, + last_assistant_message, + target, + }; + let hooks = sess.hooks(); + emit_hook_started_events(sess, turn_context, hooks.preview_stop(&request)).await; + + let mut outcome = hooks.run_stop(request).await; + emit_hook_completed_events(sess, turn_context, std::mem::take(&mut outcome.hook_events)).await; + outcome +} + pub(crate) async fn run_pre_compact_hooks( sess: &Arc, turn_context: &Arc, @@ -578,6 +647,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::SubagentStart => "SubagentStart", + HookEventName::SubagentStop => "SubagentStop", HookEventName::Stop => "Stop", }; let hook_source = match run.source { diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 86710e487139..5d3808d20fd8 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -21,11 +21,11 @@ use crate::connectors; use crate::context::ContextualUserFragment; use crate::feedback_tags; use crate::hook_runtime::PendingInputHookDisposition; -use crate::hook_runtime::emit_hook_completed_events; use crate::hook_runtime::inspect_pending_input; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; use crate::hook_runtime::run_pending_session_start_hooks; +use crate::hook_runtime::run_turn_stop_hooks; use crate::hook_runtime::run_user_prompt_submit_hooks; use crate::injection::ToolMentionKind; use crate::injection::app_id_from_path; @@ -90,7 +90,6 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageContentDeltaEvent; use codex_protocol::protocol::AgentReasoningSectionBreakEvent; -use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; @@ -518,39 +517,13 @@ pub(crate) async fn run_turn( if !needs_follow_up { last_agent_message = sampling_request_last_agent_message; - let stop_hook_permission_mode = match turn_context.approval_policy.value() { - AskForApproval::Never => "bypassPermissions", - AskForApproval::UnlessTrusted - | AskForApproval::OnFailure - | AskForApproval::OnRequest - | AskForApproval::Granular(_) => "default", - } - .to_string(); - let stop_request = codex_hooks::StopRequest { - session_id: sess.session_id().into(), - turn_id: turn_context.sub_id.clone(), - #[allow(deprecated)] - cwd: turn_context.cwd.clone(), - transcript_path: sess.hook_transcript_path().await, - model: turn_context.model_info.slug.clone(), - permission_mode: stop_hook_permission_mode, + let stop_outcome = run_turn_stop_hooks( + &sess, + &turn_context, stop_hook_active, - last_assistant_message: last_agent_message.clone(), - }; - let hooks = sess.hooks(); - for run in hooks.preview_stop(&stop_request) { - sess.send_event( - &turn_context, - EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { - turn_id: Some(turn_context.sub_id.clone()), - run, - }), - ) - .await; - } - let stop_outcome = hooks.run_stop(stop_request).await; - emit_hook_completed_events(&sess, &turn_context, stop_outcome.hook_events) - .await; + last_agent_message.clone(), + ) + .await; if stop_outcome.should_block { if let Some(hook_prompt_message) = build_hook_prompt_message(&stop_outcome.continuation_fragments) diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index a91149b46ac2..95d6b0e62da7 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1,9 +1,19 @@ use anyhow::Result; +use codex_core::StartThreadOptions; use codex_core::ThreadConfigSnapshot; use codex_core::config::AgentRoleConfig; use codex_features::Feature; use codex_protocol::ThreadId; +use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::user_input::UserInput; +use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -17,6 +27,8 @@ use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use serde_json::json; use std::fs; @@ -38,6 +50,8 @@ const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; const ROLE_MODEL: &str = "gpt-5.4"; const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High; const SUBAGENT_START_CONTEXT: &str = "subagent start context reaches child"; +const SUBAGENT_STOP_CONTINUATION: &str = "continue only the child"; +const INTERNAL_SUBAGENT_PROMPT: &str = "internal subagent: review"; fn body_contains(req: &wiremock::Request, text: &str) -> bool { let is_zstd = req @@ -111,7 +125,11 @@ fn write_home_skill(codex_home: &Path, dir: &str, name: &str, description: &str) Ok(()) } -fn write_subagent_start_hooks(home: &Path) -> Result<()> { +fn write_subagent_lifecycle_hooks( + home: &Path, + stop_prompts: &[&str], + subagent_stop_matcher: &str, +) -> Result<()> { let session_start_script_path = home.join("session_start_hook.py"); let session_start_log_path = home.join("session_start_hook_log.jsonl"); let session_start_script = format!( @@ -143,6 +161,51 @@ print(json.dumps({{"hookSpecificOutput": {{"hookEventName": "SubagentStart", "ad start_log_path = start_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)?; + let subagent_stop_script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{subagent_stop_log_path}") +block_prompts = {prompts_json} + +payload = json.load(sys.stdin) +existing = [] +if log_path.exists(): + existing = [line for line in log_path.read_text(encoding="utf-8").splitlines() if line.strip()] + +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +invocation_index = len(existing) +if invocation_index < len(block_prompts): + print(json.dumps({{"decision": "block", "reason": block_prompts[invocation_index]}})) +else: + print(json.dumps({{"systemMessage": f"subagent stop pass {{invocation_index + 1}} complete"}})) +"#, + subagent_stop_log_path = subagent_stop_log_path.display(), + prompts_json = prompts_json, + ); + + let stop_script_path = home.join("stop_hook.py"); + let stop_log_path = home.join("stop_hook_log.jsonl"); + let stop_script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{stop_log_path}") +payload = json.load(sys.stdin) +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +print(json.dumps({{"systemMessage": "root stop complete"}})) +"#, + stop_log_path = stop_log_path.display(), + ); + let hooks = serde_json::json!({ "hooks": { "SessionStart": [{ @@ -158,12 +221,27 @@ print(json.dumps({{"hookSpecificOutput": {{"hookEventName": "SubagentStart", "ad "type": "command", "command": format!("python3 {}", start_script_path.display()), }] + }], + "SubagentStop": [{ + "matcher": subagent_stop_matcher, + "hooks": [{ + "type": "command", + "command": format!("python3 {}", subagent_stop_script_path.display()), + }] + }], + "Stop": [{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", stop_script_path.display()), + }] }] } }); fs::write(&session_start_script_path, session_start_script)?; fs::write(&start_script_path, start_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())?; Ok(()) } @@ -180,6 +258,27 @@ fn read_hook_log(home: &Path, filename: &str) -> Result> .collect() } +async fn wait_for_hook_log_entries( + home: &Path, + filename: &str, + expected_len: usize, +) -> Result> { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let entries = read_hook_log(home, filename)?; + if entries.len() >= expected_len { + return Ok(entries); + } + if Instant::now() >= deadline { + anyhow::bail!( + "expected at least {expected_len} entries in {filename}, got {}", + entries.len() + ); + } + sleep(Duration::from_millis(10)).await; + } +} + async fn wait_for_spawned_thread_id(test: &TestCodex) -> Result { let deadline = Instant::now() + Duration::from_secs(2); loop { @@ -400,12 +499,12 @@ 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_start_hooks(home) { + if let Err(error) = write_subagent_lifecycle_hooks(home, &[], "worker") { panic!("failed to write subagent hook fixture: {error}"); } }) .with_config(|config| { - core_test_support::hooks::trust_discovered_hooks(config); + trust_discovered_hooks(config); config .features .enable(Feature::Collab) @@ -439,6 +538,207 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let spawn_args = serde_json::to_string(&json!({ + "message": CHILD_PROMPT, + "task_name": "child", + "agent_type": "worker", + }))?; + + mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), + sse(vec![ + ev_response_created("resp-turn1-1"), + ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_completed("resp-turn1-1"), + ]), + ) + .await; + + let first_child_request = mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) + }, + sse(vec![ + ev_response_created("resp-child-1"), + ev_assistant_message("msg-child-1", "child done first"), + ev_completed("resp-child-1"), + ]), + ) + .await; + let second_child_request = mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, SUBAGENT_STOP_CONTINUATION) && !body_contains(req, SPAWN_CALL_ID) + }, + sse(vec![ + ev_response_created("resp-child-2"), + ev_assistant_message("msg-child-2", "child done final"), + ev_completed("resp-child-2"), + ]), + ) + .await; + + let _turn1_followup = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), + sse(vec![ + ev_response_created("resp-turn1-2"), + ev_assistant_message("msg-turn1-2", "parent done"), + ev_completed("resp-turn1-2"), + ]), + ) + .await; + let internal_request = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, INTERNAL_SUBAGENT_PROMPT), + sse(vec![ + ev_response_created("resp-internal-1"), + ev_assistant_message("msg-internal-1", "internal subagent done"), + ev_completed("resp-internal-1"), + ]), + ) + .await; + + let test = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_subagent_lifecycle_hooks(home, &[SUBAGENT_STOP_CONTINUATION], "") + { + panic!("failed to write subagent hook fixture: {error}"); + } + }) + .with_config(|config| { + trust_discovered_hooks(config); + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + + test.submit_turn(TURN_1_PROMPT).await?; + let _ = wait_for_requests(&first_child_request).await?; + let _ = wait_for_requests(&second_child_request).await?; + + let subagent_stop_inputs = wait_for_hook_log_entries( + test.codex_home_path(), + "subagent_stop_hook_log.jsonl", + /*expected_len*/ 2, + ) + .await?; + assert_eq!(subagent_stop_inputs.len(), 2); + assert_eq!( + subagent_stop_inputs + .iter() + .map(|input| input["stop_hook_active"].as_bool()) + .collect::>(), + vec![Some(false), Some(true)] + ); + assert_eq!( + subagent_stop_inputs[0]["agent_type"].as_str(), + Some("worker") + ); + let parent_transcript_path = subagent_stop_inputs[0]["transcript_path"] + .as_str() + .expect("SubagentStop should include parent transcript_path"); + let agent_transcript_path = subagent_stop_inputs[0]["agent_transcript_path"] + .as_str() + .expect("SubagentStop should include agent_transcript_path"); + assert_ne!(parent_transcript_path, agent_transcript_path); + assert_eq!( + subagent_stop_inputs[1]["transcript_path"].as_str(), + Some(parent_transcript_path) + ); + assert_eq!( + subagent_stop_inputs[1]["agent_transcript_path"].as_str(), + Some(agent_transcript_path) + ); + assert_eq!( + subagent_stop_inputs[0]["last_assistant_message"].as_str(), + Some("child done first") + ); + + let stop_inputs = read_hook_log(test.codex_home_path(), "stop_hook_log.jsonl")?; + assert!( + stop_inputs + .iter() + .all(|input| input["last_assistant_message"].as_str() != Some("child done first")), + "child completion should not invoke the normal Stop hook" + ); + let stop_input_count = stop_inputs.len(); + + // This matcher would catch the old synthetic "review" SubagentStop target + // because the SubagentStop hook above intentionally matches all agent types. + let internal_thread = test + .thread_manager + .start_thread_with_options(StartThreadOptions { + config: test.config.clone(), + initial_history: InitialHistory::New, + session_source: Some(SessionSource::SubAgent(SubAgentSource::Review)), + thread_source: None, + dynamic_tools: Vec::new(), + persist_extended_history: false, + metrics_service_name: None, + parent_trace: None, + environments: Vec::new(), + }) + .await?; + + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.cwd_path()); + internal_thread + .thread + .submit(Op::UserTurn { + environments: None, + items: vec![UserInput::Text { + text: INTERNAL_SUBAGENT_PROMPT.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.config.cwd.to_path_buf(), + approval_policy: AskForApproval::Never, + approvals_reviewer: None, + sandbox_policy, + permission_profile, + model: internal_thread.session_configured.model.clone(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + let turn_id = wait_for_event_match(internal_thread.thread.as_ref(), |event| match event { + EventMsg::TurnStarted(event) => Some(event.turn_id.clone()), + _ => None, + }) + .await; + wait_for_event_match(internal_thread.thread.as_ref(), |event| match event { + EventMsg::TurnComplete(event) if event.turn_id == turn_id => Some(()), + _ => None, + }) + .await; + let requests = wait_for_requests(&internal_request).await?; + assert_eq!(requests.len(), 1); + + let subagent_stop_inputs_after_internal = + read_hook_log(test.codex_home_path(), "subagent_stop_hook_log.jsonl")?; + assert_eq!(subagent_stop_inputs_after_internal, subagent_stop_inputs); + + let stop_inputs_after_internal = read_hook_log(test.codex_home_path(), "stop_hook_log.jsonl")?; + assert_eq!(stop_inputs_after_internal.len(), stop_input_count); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn subagent_notification_is_included_without_wait() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json index 44e775a03d73..7d63b56c960e 100644 --- a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json @@ -12,6 +12,7 @@ "SessionStart", "UserPromptSubmit", "SubagentStart", + "SubagentStop", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json index b762c549c5b7..964c3ba62485 100644 --- a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json @@ -18,6 +18,7 @@ "SessionStart", "UserPromptSubmit", "SubagentStart", + "SubagentStop", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json index c4c41324481c..a74abd14e8d0 100644 --- a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -12,6 +12,7 @@ "SessionStart", "UserPromptSubmit", "SubagentStart", + "SubagentStop", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json index d8a0f4648246..e1abb88a5263 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -12,6 +12,7 @@ "SessionStart", "UserPromptSubmit", "SubagentStart", + "SubagentStop", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json b/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json index 16140f326b42..4e645bb918a6 100644 --- a/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json @@ -12,6 +12,7 @@ "SessionStart", "UserPromptSubmit", "SubagentStart", + "SubagentStop", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/subagent-stop.command.input.schema.json b/codex-rs/hooks/schema/generated/subagent-stop.command.input.schema.json new file mode 100644 index 000000000000..19646db1d2b1 --- /dev/null +++ b/codex-rs/hooks/schema/generated/subagent-stop.command.input.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "agent_id": { + "type": "string" + }, + "agent_transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "agent_type": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "SubagentStop", + "type": "string" + }, + "last_assistant_message": { + "$ref": "#/definitions/NullableString" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "stop_hook_active": { + "type": "boolean" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "agent_id", + "agent_transcript_path", + "agent_type", + "cwd", + "hook_event_name", + "last_assistant_message", + "model", + "permission_mode", + "session_id", + "stop_hook_active", + "transcript_path", + "turn_id" + ], + "title": "subagent-stop.command.input", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json b/codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json new file mode 100644 index 000000000000..0cb1dd2a5701 --- /dev/null +++ b/codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "BlockDecisionWire": { + "enum": [ + "block" + ], + "type": "string" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "decision": { + "allOf": [ + { + "$ref": "#/definitions/BlockDecisionWire" + } + ], + "default": null + }, + "reason": { + "default": null, + "description": "Claude requires `reason` when `decision` is `block`; we enforce that semantic rule during output parsing rather than in the JSON schema.", + "type": "string" + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "subagent-stop.command.output", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json index d4d66d29767a..61c530ce04aa 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json @@ -18,6 +18,7 @@ "SessionStart", "UserPromptSubmit", "SubagentStart", + "SubagentStop", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 51c6d6672e03..50822bfc9612 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -50,6 +50,7 @@ pub(crate) fn select_handlers_for_matcher_inputs( | HookEventName::PostToolUse | HookEventName::SessionStart | HookEventName::SubagentStart + | HookEventName::SubagentStop | HookEventName::PreCompact | HookEventName::PostCompact => { if matcher_inputs.is_empty() { @@ -147,6 +148,7 @@ fn scope_for_event(event_name: HookEventName) -> HookScope { | HookEventName::PreCompact | HookEventName::PostCompact | HookEventName::UserPromptSubmit + | HookEventName::SubagentStop | HookEventName::Stop => HookScope::Turn, } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 7cbbd7a02713..859fc5406950 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -71,6 +71,7 @@ impl ConfiguredHandler { codex_protocol::protocol::HookEventName::SessionStart => "session-start", codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit", codex_protocol::protocol::HookEventName::SubagentStart => "subagent-start", + codex_protocol::protocol::HookEventName::SubagentStop => "subagent-stop", codex_protocol::protocol::HookEventName::Stop => "stop", } } diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs index 1ea44c0903cc..11b44395c8bb 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -87,6 +87,7 @@ use crate::schema::PreToolUsePermissionDecisionWire; use crate::schema::SessionStartCommandOutputWire; use crate::schema::StopCommandOutputWire; use crate::schema::SubagentStartCommandOutputWire; +use crate::schema::SubagentStopCommandOutputWire; use crate::schema::UserPromptSubmitCommandOutputWire; pub(crate) fn parse_session_start(stdout: &str) -> Option { @@ -280,22 +281,46 @@ pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option Option { let wire: StopCommandOutputWire = parse_json(stdout)?; - let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block)); + Some(stop_output( + wire.universal, + wire.decision, + wire.reason, + "Stop", + )) +} + +pub(crate) fn parse_subagent_stop(stdout: &str) -> Option { + let wire: SubagentStopCommandOutputWire = parse_json(stdout)?; + Some(stop_output( + wire.universal, + wire.decision, + wire.reason, + "SubagentStop", + )) +} + +fn stop_output( + universal: HookUniversalOutputWire, + decision: Option, + reason: Option, + event_name: &str, +) -> StopOutput { + let should_block = matches!(decision, Some(BlockDecisionWire::Block)); let invalid_block_reason = if should_block - && match wire.reason.as_deref() { + && match reason.as_deref() { Some(reason) => reason.trim().is_empty(), None => true, } { - Some(invalid_block_message("Stop")) + Some(invalid_block_message(event_name)) } else { None }; - Some(StopOutput { - universal: UniversalOutput::from(wire.universal), + StopOutput { + universal: UniversalOutput::from(universal), should_block: should_block && invalid_block_reason.is_none(), - reason: wire.reason, + reason, invalid_block_reason, - }) + } } impl From for UniversalOutput { diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index 2b1994f28d7a..655e28312471 100644 --- a/codex-rs/hooks/src/engine/schema_loader.rs +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -18,6 +18,8 @@ pub(crate) struct GeneratedHookSchemas { pub session_start_command_output: Value, pub subagent_start_command_input: Value, pub subagent_start_command_output: Value, + pub subagent_stop_command_input: Value, + pub subagent_stop_command_output: Value, pub user_prompt_submit_command_input: Value, pub user_prompt_submit_command_output: Value, pub stop_command_input: Value, @@ -83,6 +85,14 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas { "subagent-start.command.output", include_str!("../../schema/generated/subagent-start.command.output.schema.json"), ), + subagent_stop_command_input: parse_json_schema( + "subagent-stop.command.input", + include_str!("../../schema/generated/subagent-stop.command.input.schema.json"), + ), + subagent_stop_command_output: parse_json_schema( + "subagent-stop.command.output", + include_str!("../../schema/generated/subagent-stop.command.output.schema.json"), + ), user_prompt_submit_command_input: parse_json_schema( "user-prompt-submit.command.input", include_str!("../../schema/generated/user-prompt-submit.command.input.schema.json"), @@ -130,6 +140,8 @@ mod tests { assert_eq!(schemas.session_start_command_output["type"], "object"); assert_eq!(schemas.subagent_start_command_input["type"], "object"); assert_eq!(schemas.subagent_start_command_output["type"], "object"); + assert_eq!(schemas.subagent_stop_command_input["type"], "object"); + assert_eq!(schemas.subagent_stop_command_output["type"], "object"); assert_eq!(schemas.user_prompt_submit_command_input["type"], "object"); assert_eq!(schemas.user_prompt_submit_command_output["type"], "object"); assert_eq!(schemas.stop_command_input["type"], "object"); diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index f96de580201a..c97129ebef11 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -105,6 +105,7 @@ pub(crate) fn matcher_pattern_for_event( | HookEventName::PostToolUse | HookEventName::SessionStart | HookEventName::SubagentStart + | HookEventName::SubagentStop | HookEventName::PreCompact | HookEventName::PostCompact => matcher, HookEventName::UserPromptSubmit | HookEventName::Stop => None, diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 3cd2d4427019..a7cf33e196c2 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -18,6 +18,7 @@ use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::NullableString; use crate::schema::StopCommandInput; +use crate::schema::SubagentStopCommandInput; #[derive(Debug, Clone)] pub struct StopRequest { @@ -29,9 +30,36 @@ pub struct StopRequest { pub permission_mode: String, pub stop_hook_active: bool, pub last_assistant_message: Option, + pub target: StopHookTarget, } -#[derive(Debug)] +#[derive(Debug, Clone)] +pub enum StopHookTarget { + Stop, + SubagentStop { + agent_id: String, + agent_type: String, + agent_transcript_path: Option, + }, +} + +impl StopHookTarget { + fn event_name(&self) -> HookEventName { + match self { + Self::Stop => HookEventName::Stop, + Self::SubagentStop { .. } => HookEventName::SubagentStop, + } + } + + fn matcher_input(&self) -> Option<&str> { + match self { + Self::Stop => None, + Self::SubagentStop { agent_type, .. } => Some(agent_type.as_str()), + } + } +} + +#[derive(Debug, Default)] pub struct StopOutcome { pub hook_events: Vec, pub should_stop: bool, @@ -52,12 +80,16 @@ struct StopHandlerData { pub(crate) fn preview( handlers: &[ConfiguredHandler], - _request: &StopRequest, + request: &StopRequest, ) -> Vec { - dispatcher::select_handlers(handlers, HookEventName::Stop, /*matcher_input*/ None) - .into_iter() - .map(|handler| dispatcher::running_summary(&handler)) - .collect() + dispatcher::select_handlers( + handlers, + request.target.event_name(), + request.target.matcher_input(), + ) + .into_iter() + .map(|handler| dispatcher::running_summary(&handler)) + .collect() } pub(crate) async fn run( @@ -65,8 +97,11 @@ pub(crate) async fn run( shell: &CommandShell, request: StopRequest, ) -> StopOutcome { - let matched = - dispatcher::select_handlers(handlers, HookEventName::Stop, /*matcher_input*/ None); + let matched = dispatcher::select_handlers( + handlers, + request.target.event_name(), + request.target.matcher_input(), + ); if matched.is_empty() { return StopOutcome { hook_events: Vec::new(), @@ -78,24 +113,85 @@ pub(crate) async fn run( }; } - 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( - matched, - Some(request.turn_id), - format!("failed to serialize stop hook input: {error}"), - )); + let (input_json, parse_completed) = match request.target { + StopHookTarget::Stop => { + let input = 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(), + ), + }; + match serde_json::to_string(&input) { + Ok(input_json) => ( + input_json, + parse_completed + as fn( + &ConfiguredHandler, + CommandRunResult, + Option, + ) + -> dispatcher::ParsedHandler, + ), + Err(error) => { + return serialization_failure_outcome( + common::serialization_failure_hook_events( + matched, + Some(request.turn_id), + format!("failed to serialize stop hook input: {error}"), + ), + ); + } + } + } + StopHookTarget::SubagentStop { + agent_id, + agent_type, + agent_transcript_path, + } => { + let input = SubagentStopCommandInput { + session_id: request.session_id.to_string(), + turn_id: request.turn_id.clone(), + transcript_path: NullableString::from_path(request.transcript_path.clone()), + agent_transcript_path: NullableString::from_path(agent_transcript_path), + cwd: request.cwd.display().to_string(), + hook_event_name: "SubagentStop".to_string(), + model: request.model.clone(), + permission_mode: request.permission_mode.clone(), + stop_hook_active: request.stop_hook_active, + agent_id, + agent_type, + last_assistant_message: NullableString::from_string( + request.last_assistant_message.clone(), + ), + }; + match serde_json::to_string(&input) { + Ok(input_json) => ( + input_json, + parse_subagent_stop_completed + as fn( + &ConfiguredHandler, + CommandRunResult, + Option, + ) + -> dispatcher::ParsedHandler, + ), + Err(error) => { + return serialization_failure_outcome( + common::serialization_failure_hook_events( + matched, + Some(request.turn_id), + format!("failed to serialize subagent stop hook input: {error}"), + ), + ); + } + } } }; @@ -125,6 +221,39 @@ fn parse_completed( handler: &ConfiguredHandler, run_result: CommandRunResult, turn_id: Option, +) -> dispatcher::ParsedHandler { + parse_stop_completed( + handler, + run_result, + turn_id, + "Stop", + "stop", + output_parser::parse_stop, + ) +} + +fn parse_subagent_stop_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler { + parse_stop_completed( + handler, + run_result, + turn_id, + "SubagentStop", + "subagent stop", + output_parser::parse_subagent_stop, + ) +} + +fn parse_stop_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, + hook_name: &str, + hook_label: &str, + parse_output: fn(&str) -> Option, ) -> dispatcher::ParsedHandler { let mut entries = Vec::new(); let mut status = HookRunStatus::Completed; @@ -146,7 +275,7 @@ fn parse_completed( Some(0) => { let trimmed_stdout = run_result.stdout.trim(); if trimmed_stdout.is_empty() { - } else if let Some(parsed) = output_parser::parse_stop(&run_result.stdout) { + } else if let Some(parsed) = parse_output(&run_result.stdout) { if let Some(system_message) = parsed.universal.system_message { entries.push(HookOutputEntry { kind: HookOutputEntryKind::Warning, @@ -186,9 +315,9 @@ fn parse_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: - "Stop hook returned decision:block without a non-empty reason" - .to_string(), + text: format!( + "{hook_name} hook returned decision:block without a non-empty reason" + ), }); } } @@ -196,7 +325,7 @@ fn parse_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: "hook returned invalid stop hook JSON output".to_string(), + text: format!("hook returned invalid {hook_label} hook JSON output"), }); } } @@ -214,9 +343,9 @@ fn parse_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: - "Stop hook exited with code 2 but did not write a continuation prompt to stderr" - .to_string(), + text: format!( + "{hook_name} hook exited with code 2 but did not write a continuation prompt to stderr" + ), }); } } diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 93bbe72ba544..2bd0cc2d6e5c 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -15,7 +15,7 @@ pub use declarations::PluginHookDeclaration; pub use declarations::plugin_hook_declarations; pub use engine::HookListEntry; /// Hook event names as they appear in hooks JSON and config files. -pub const HOOK_EVENT_NAMES: [&str; 9] = [ +pub const HOOK_EVENT_NAMES: [&str; 10] = [ "PreToolUse", "PermissionRequest", "PostToolUse", @@ -24,6 +24,7 @@ pub const HOOK_EVENT_NAMES: [&str; 9] = [ "SessionStart", "UserPromptSubmit", "SubagentStart", + "SubagentStop", "Stop", ]; @@ -32,7 +33,7 @@ pub const HOOK_EVENT_NAMES: [&str; 9] = [ /// Other events can appear in hooks JSON, but Codex ignores their matcher /// fields because those events do not dispatch against a tool, compaction /// trigger, or session-start source. -pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 7] = [ +pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 8] = [ "PreToolUse", "PermissionRequest", "PostToolUse", @@ -40,6 +41,7 @@ pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 7] = [ "PostCompact", "SessionStart", "SubagentStart", + "SubagentStop", ]; pub use events::compact::PostCompactRequest; @@ -57,6 +59,7 @@ pub use events::session_start::SessionStartOutcome; pub use events::session_start::SessionStartRequest; pub use events::session_start::SessionStartSource; pub use events::session_start::SessionStartTarget; +pub use events::stop::StopHookTarget; pub use events::stop::StopOutcome; pub use events::stop::StopRequest; pub use events::user_prompt_submit::UserPromptSubmitOutcome; @@ -87,6 +90,7 @@ pub fn hook_event_key_label(event_name: HookEventName) -> &'static str { HookEventName::SessionStart => "session_start", HookEventName::UserPromptSubmit => "user_prompt_submit", HookEventName::SubagentStart => "subagent_start", + HookEventName::SubagentStop => "subagent_stop", HookEventName::Stop => "stop", } } diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index 96d5f790be16..8b11407b6518 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -29,6 +29,8 @@ const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input const USER_PROMPT_SUBMIT_OUTPUT_FIXTURE: &str = "user-prompt-submit.command.output.schema.json"; const SUBAGENT_START_INPUT_FIXTURE: &str = "subagent-start.command.input.schema.json"; const SUBAGENT_START_OUTPUT_FIXTURE: &str = "subagent-start.command.output.schema.json"; +const SUBAGENT_STOP_INPUT_FIXTURE: &str = "subagent-stop.command.input.schema.json"; +const SUBAGENT_STOP_OUTPUT_FIXTURE: &str = "subagent-stop.command.output.schema.json"; const STOP_INPUT_FIXTURE: &str = "stop.command.input.schema.json"; const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json"; @@ -91,6 +93,8 @@ pub(crate) enum HookEventNameWire { UserPromptSubmit, #[serde(rename = "SubagentStart")] SubagentStart, + #[serde(rename = "SubagentStop")] + SubagentStop, #[serde(rename = "Stop")] Stop, } @@ -399,6 +403,21 @@ pub(crate) struct StopCommandOutputWire { pub reason: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[schemars(rename = "subagent-stop.command.output")] +pub(crate) struct SubagentStopCommandOutputWire { + #[serde(flatten)] + pub universal: HookUniversalOutputWire, + #[serde(default)] + pub decision: Option, + /// Claude requires `reason` when `decision` is `block`; we enforce that + /// semantic rule during output parsing rather than in the JSON schema. + #[serde(default)] + pub reason: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub(crate) enum BlockDecisionWire { #[serde(rename = "block")] @@ -495,6 +514,27 @@ pub(crate) struct StopCommandInput { pub last_assistant_message: NullableString, } +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "subagent-stop.command.input")] +pub(crate) struct SubagentStopCommandInput { + 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 agent_transcript_path: NullableString, + pub cwd: String, + #[schemars(schema_with = "subagent_stop_hook_event_name_schema")] + pub hook_event_name: String, + pub model: String, + #[schemars(schema_with = "permission_mode_schema")] + pub permission_mode: String, + pub stop_hook_active: bool, + pub agent_id: String, + pub agent_type: String, + pub last_assistant_message: NullableString, +} + pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { let generated_dir = schema_root.join(GENERATED_DIR); ensure_empty_dir(&generated_dir)?; @@ -563,6 +603,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { &generated_dir.join(SUBAGENT_START_OUTPUT_FIXTURE), schema_json::()?, )?; + write_schema( + &generated_dir.join(SUBAGENT_STOP_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(SUBAGENT_STOP_OUTPUT_FIXTURE), + schema_json::()?, + )?; write_schema( &generated_dir.join(STOP_INPUT_FIXTURE), schema_json::()?, @@ -658,6 +706,10 @@ fn subagent_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("SubagentStart") } +fn subagent_stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("SubagentStop") +} + fn stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("Stop") } @@ -730,8 +782,11 @@ mod tests { use super::STOP_OUTPUT_FIXTURE; use super::SUBAGENT_START_INPUT_FIXTURE; use super::SUBAGENT_START_OUTPUT_FIXTURE; + use super::SUBAGENT_STOP_INPUT_FIXTURE; + use super::SUBAGENT_STOP_OUTPUT_FIXTURE; use super::StopCommandInput; use super::SubagentStartCommandInput; + use super::SubagentStopCommandInput; use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE; use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE; use super::UserPromptSubmitCommandInput; @@ -791,6 +846,12 @@ mod tests { SUBAGENT_START_OUTPUT_FIXTURE => { include_str!("../schema/generated/subagent-start.command.output.schema.json") } + SUBAGENT_STOP_INPUT_FIXTURE => { + include_str!("../schema/generated/subagent-stop.command.input.schema.json") + } + SUBAGENT_STOP_OUTPUT_FIXTURE => { + include_str!("../schema/generated/subagent-stop.command.output.schema.json") + } STOP_INPUT_FIXTURE => { include_str!("../schema/generated/stop.command.input.schema.json") } @@ -828,6 +889,8 @@ mod tests { USER_PROMPT_SUBMIT_OUTPUT_FIXTURE, SUBAGENT_START_INPUT_FIXTURE, SUBAGENT_START_OUTPUT_FIXTURE, + SUBAGENT_STOP_INPUT_FIXTURE, + SUBAGENT_STOP_OUTPUT_FIXTURE, STOP_INPUT_FIXTURE, STOP_OUTPUT_FIXTURE, ] { @@ -875,6 +938,11 @@ mod tests { .expect("serialize subagent start input schema"), ) .expect("parse subagent start input schema"); + let subagent_stop: Value = serde_json::from_slice( + &schema_json::() + .expect("serialize subagent stop input schema"), + ) + .expect("parse subagent stop input schema"); let stop: Value = serde_json::from_slice( &schema_json::().expect("serialize stop input schema"), ) @@ -888,6 +956,7 @@ mod tests { &post_compact, &user_prompt_submit, &subagent_start, + &subagent_stop, &stop, ] { assert_eq!(schema["properties"]["turn_id"]["type"], "string"); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 36fca9fbab07..1d6f1e51ffa9 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1459,6 +1459,7 @@ pub enum HookEventName { SessionStart, UserPromptSubmit, SubagentStart, + SubagentStop, Stop, } diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 1202265aa2ec..ce31abf40431 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -737,6 +737,7 @@ fn event_label(event_name: HookEventName) -> &'static str { HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::SubagentStart => "SubagentStart", + HookEventName::SubagentStop => "SubagentStop", HookEventName::Stop => "Stop", } } @@ -751,6 +752,7 @@ fn event_description(event_name: HookEventName) -> &'static str { HookEventName::SessionStart => "When a new session starts", HookEventName::UserPromptSubmit => "When the user submits a prompt", HookEventName::SubagentStart => "When a subagent is created", + HookEventName::SubagentStop => "Right before a subagent ends its turn", HookEventName::Stop => "Right before Codex ends its turn", } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap index 836a65c3f1a6..eaf06f6c45ec 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap @@ -15,6 +15,7 @@ expression: "render_lines(&view, 112)" SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt SubagentStart 0 0 When a subagent is created + SubagentStop 0 0 Right before a subagent ends its turn Stop 0 0 Right before Codex ends its turn Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap index fabd9c1707bd..7dc71463dc17 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap @@ -19,6 +19,7 @@ expression: "render_lines(&view, 112)" SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt SubagentStart 0 0 When a subagent is created + SubagentStop 0 0 Right before a subagent ends its turn Stop 0 0 Right before Codex ends its turn Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap index dd4e5e33b584..c18657d8a172 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap @@ -17,6 +17,7 @@ expression: "render_lines(&view, 112)" SessionStart 0 0 0 When a new session starts UserPromptSubmit 0 0 0 When the user submits a prompt SubagentStart 0 0 0 When a subagent is created + SubagentStop 0 0 0 Right before a subagent ends its turn Stop 0 0 0 Right before Codex ends its turn Press t to trust all; enter to review hooks; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap index 9a532f563db7..99922ac63049 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap @@ -18,6 +18,7 @@ expression: popup SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt SubagentStart 0 0 When a subagent is created + SubagentStop 0 0 Right before a subagent ends its turn Stop 0 0 Right before Codex ends its turn Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index f6a6001bdb97..3ed508769cd0 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1573,6 +1573,7 @@ fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'s codex_app_server_protocol::HookEventName::SessionStart => "SessionStart", codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_app_server_protocol::HookEventName::SubagentStart => "SubagentStart", + codex_app_server_protocol::HookEventName::SubagentStop => "SubagentStop", codex_app_server_protocol::HookEventName::Stop => "Stop", } } diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index 824fdc1de837..6a4e8498d949 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -721,6 +721,7 @@ fn hook_event_label(event_name: HookEventName) -> &'static str { HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::SubagentStart => "SubagentStart", + HookEventName::SubagentStop => "SubagentStop", HookEventName::Stop => "Stop", } } From b2e51b85ee0452ef4b6ae3a6e7ade7a65db8f80c Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 18:42:10 +0000 Subject: [PATCH 03/10] Add subagent identity to hook inputs --- codex-rs/core/src/hook_runtime.rs | 39 ++++-- .../tests/suite/subagent_notifications.rs | 41 ++++++ ...rmission-request.command.input.schema.json | 6 + .../post-compact.command.input.schema.json | 6 + .../post-tool-use.command.input.schema.json | 6 + .../pre-compact.command.input.schema.json | 6 + .../pre-tool-use.command.input.schema.json | 6 + ...er-prompt-submit.command.input.schema.json | 6 + codex-rs/hooks/src/engine/mod_tests.rs | 7 + codex-rs/hooks/src/events/common.rs | 7 + codex-rs/hooks/src/events/compact.rs | 11 ++ .../hooks/src/events/permission_request.rs | 5 + codex-rs/hooks/src/events/post_tool_use.rs | 6 + codex-rs/hooks/src/events/pre_tool_use.rs | 6 + .../hooks/src/events/user_prompt_submit.rs | 5 + codex-rs/hooks/src/lib.rs | 1 + codex-rs/hooks/src/schema.rs | 131 ++++++++++++++++++ 17 files changed, 281 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 6acae02578a5..4d30ccce057c 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -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, + turn_context: &TurnContext, +) -> Option { + 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, - agent_role: &Option, -) -> SubagentHookMetadata { - SubagentHookMetadata { +fn subagent_hook_context(sess: &Arc, agent_role: &Option) -> SubagentHookContext { + SubagentHookContext { agent_id: sess.thread_id().to_string(), agent_type: agent_role .clone() diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 95d6b0e62da7..4d4358deded5 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -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); diff --git a/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json b/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json index 55b3843c0b89..9ee8996db169 100644 --- a/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json @@ -10,6 +10,12 @@ } }, "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, "cwd": { "type": "string" }, diff --git a/codex-rs/hooks/schema/generated/post-compact.command.input.schema.json b/codex-rs/hooks/schema/generated/post-compact.command.input.schema.json index e80ed092b77d..3131f3a776be 100644 --- a/codex-rs/hooks/schema/generated/post-compact.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/post-compact.command.input.schema.json @@ -10,6 +10,12 @@ } }, "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, "cwd": { "type": "string" }, diff --git a/codex-rs/hooks/schema/generated/post-tool-use.command.input.schema.json b/codex-rs/hooks/schema/generated/post-tool-use.command.input.schema.json index 1ec5fb3082df..f92af1dd3249 100644 --- a/codex-rs/hooks/schema/generated/post-tool-use.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/post-tool-use.command.input.schema.json @@ -10,6 +10,12 @@ } }, "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, "cwd": { "type": "string" }, diff --git a/codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json b/codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json index 816fae23c8dd..54e9a8b7f4f7 100644 --- a/codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json @@ -10,6 +10,12 @@ } }, "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, "cwd": { "type": "string" }, diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json index f9cde0102028..48dd4c571010 100644 --- a/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json @@ -10,6 +10,12 @@ } }, "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, "cwd": { "type": "string" }, diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json index be5e16fc5071..6a10a9f75c19 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json @@ -10,6 +10,12 @@ } }, "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, "cwd": { "type": "string" }, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index c3b75716264b..9b642503a068 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -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(), diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index c97129ebef11..997eac139f4c 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -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) -> Option { if chunks.is_empty() { None diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs index 469fdda232f7..cb3080219a56 100644 --- a/codex-rs/hooks/src/events/compact.rs +++ b/codex-rs/hooks/src/events/compact.rs @@ -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, pub cwd: AbsolutePathBuf, pub transcript_path: Option, pub model: String, @@ -32,6 +34,7 @@ pub struct PreCompactRequest { pub struct PostCompactRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: AbsolutePathBuf, pub transcript_path: Option, pub model: String, @@ -120,9 +123,12 @@ pub(crate) async fn run_pre( } fn pre_command_input_json(request: &PreCompactRequest) -> Result { + 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 { + 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(), diff --git a/codex-rs/hooks/src/events/permission_request.rs b/codex-rs/hooks/src/events/permission_request.rs index 79d06082369a..db7970f02dab 100644 --- a/codex-rs/hooks/src/events/permission_request.rs +++ b/codex-rs/hooks/src/events/permission_request.rs @@ -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, pub cwd: PathBuf, pub transcript_path: Option, 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(), diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 801c5f09e9c2..f096de011098 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -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, pub cwd: AbsolutePathBuf, pub transcript_path: Option, 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 { + 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(), diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index b21daf063b86..b3579aba824e 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -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, pub cwd: AbsolutePathBuf, pub transcript_path: Option, 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 { + 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(), diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index eb152a1f48e8..2934bd352397 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -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, pub cwd: AbsolutePathBuf, pub transcript_path: Option, 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(), diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 2bd0cc2d6e5c..67d5ba3758fc 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -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", diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index 8b11407b6518..f84926d942a6 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -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, + pub agent_type: Option, +} + +impl From> 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, 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::().expect("serialize pre tool use input schema"), + schema_json::() + .expect("serialize permission request input schema"), + schema_json::().expect("serialize post tool use input schema"), + schema_json::().expect("serialize pre compact input schema"), + schema_json::().expect("serialize post compact input schema"), + schema_json::() + .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); + } } From 991bc308ab4f281159884382dba447632e56ce18 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 11:46:41 -0700 Subject: [PATCH 04/10] Wait for subagent hook logs in test --- .../tests/suite/subagent_notifications.rs | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index a91149b46ac2..c74269e58546 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -180,6 +180,27 @@ fn read_hook_log(home: &Path, filename: &str) -> Result> .collect() } +async fn wait_for_hook_log( + home: &Path, + filename: &str, + expected_len: usize, +) -> Result> { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let inputs = read_hook_log(home, filename)?; + if inputs.len() >= expected_len { + return Ok(inputs); + } + if Instant::now() >= deadline { + anyhow::bail!( + "expected at least {expected_len} entries in {filename}, got {}", + inputs.len() + ); + } + sleep(Duration::from_millis(10)).await; + } +} + async fn wait_for_spawned_thread_id(test: &TestCodex) -> Result { let deadline = Instant::now() + Duration::from_secs(2); loop { @@ -418,7 +439,8 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( let child_requests = wait_for_requests(&child_request_log).await?; assert_eq!(child_requests.len(), 1); - let start_inputs = read_hook_log(test.codex_home_path(), "subagent_start_hook_log.jsonl")?; + let start_inputs = + wait_for_hook_log(test.codex_home_path(), "subagent_start_hook_log.jsonl", 1).await?; assert_eq!(start_inputs.len(), 1); assert_eq!(start_inputs[0]["agent_type"].as_str(), Some("worker")); let spawned_id = wait_for_spawned_thread_id(&test).await?; @@ -428,7 +450,7 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( ); let session_start_inputs = - read_hook_log(test.codex_home_path(), "session_start_hook_log.jsonl")?; + wait_for_hook_log(test.codex_home_path(), "session_start_hook_log.jsonl", 1).await?; assert_eq!(session_start_inputs.len(), 1); assert_eq!(session_start_inputs[0]["source"].as_str(), Some("startup")); assert_ne!( From 8dfeb972c7aa703039577a69fe03ee33827a95b8 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 22:00:00 +0000 Subject: [PATCH 05/10] Address subagent start hook review feedback --- codex-rs/core/src/hook_runtime.rs | 34 +-- codex-rs/core/src/session/tests.rs | 6 +- .../tests/suite/subagent_notifications.rs | 16 +- codex-rs/hooks/src/events/session_start.rs | 204 ++++++++++-------- codex-rs/hooks/src/lib.rs | 2 +- 5 files changed, 143 insertions(+), 119 deletions(-) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 8647c3ef5baa..97c724d3c19c 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -13,7 +13,7 @@ use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; -use codex_hooks::SessionStartTarget; +use codex_hooks::StartHookTarget; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; use codex_otel::HOOK_RUN_DURATION_METRIC; @@ -117,6 +117,9 @@ pub(crate) async fn run_pending_session_start_hooks( return false; }; + // Pending session-start hooks are reused to dispatch thread-spawn subagent + // starts. Other subagent sessions are internal/system work and do not run + // start hooks. let target = match &turn_context.session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) if matches!( @@ -124,15 +127,17 @@ pub(crate) async fn run_pending_session_start_hooks( codex_hooks::SessionStartSource::Startup ) => { - let metadata = subagent_hook_metadata(sess, agent_role); - SessionStartTarget::SubagentStart { + let agent_type = agent_role + .clone() + .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()); + StartHookTarget::SubagentStart { turn_id: turn_context.sub_id.clone(), - agent_id: metadata.agent_id, - agent_type: metadata.agent_type, + agent_id: sess.thread_id().to_string(), + agent_type, } } SessionSource::SubAgent(_) => return false, - _ => SessionStartTarget::SessionStart { + _ => StartHookTarget::SessionStart { source: session_start_source, }, }; @@ -618,23 +623,6 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String { .to_string() } -struct SubagentHookMetadata { - agent_id: String, - agent_type: String, -} - -fn subagent_hook_metadata( - sess: &Arc, - agent_role: &Option, -) -> SubagentHookMetadata { - SubagentHookMetadata { - 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", diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4f441ea08fd8..eaeaf772957b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -532,7 +532,7 @@ async fn preview_session_start_hooks( transcript_path: None, model: "gpt-5.2".to_string(), permission_mode: "default".to_string(), - target: codex_hooks::SessionStartTarget::SessionStart { + target: codex_hooks::StartHookTarget::SessionStart { source: codex_hooks::SessionStartSource::Startup, }, }), @@ -1306,7 +1306,7 @@ async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> { transcript_path: None, model: "gpt-5.2".to_string(), permission_mode: "default".to_string(), - target: codex_hooks::SessionStartTarget::SessionStart { + target: codex_hooks::StartHookTarget::SessionStart { source: codex_hooks::SessionStartSource::Startup, }, }; @@ -1413,7 +1413,7 @@ async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> { transcript_path: None, model: "gpt-5.2".to_string(), permission_mode: "default".to_string(), - target: codex_hooks::SessionStartTarget::SessionStart { + target: codex_hooks::StartHookTarget::SessionStart { source: codex_hooks::SessionStartSource::Startup, }, }; diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index c74269e58546..a289cc8f7eb4 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -439,8 +439,12 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( let child_requests = wait_for_requests(&child_request_log).await?; assert_eq!(child_requests.len(), 1); - let start_inputs = - wait_for_hook_log(test.codex_home_path(), "subagent_start_hook_log.jsonl", 1).await?; + let start_inputs = wait_for_hook_log( + test.codex_home_path(), + "subagent_start_hook_log.jsonl", + /*expected_len*/ 1, + ) + .await?; assert_eq!(start_inputs.len(), 1); assert_eq!(start_inputs[0]["agent_type"].as_str(), Some("worker")); let spawned_id = wait_for_spawned_thread_id(&test).await?; @@ -449,8 +453,12 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( Some(spawned_id.as_str()) ); - let session_start_inputs = - wait_for_hook_log(test.codex_home_path(), "session_start_hook_log.jsonl", 1).await?; + let session_start_inputs = wait_for_hook_log( + test.codex_home_path(), + "session_start_hook_log.jsonl", + /*expected_len*/ 1, + ) + .await?; assert_eq!(session_start_inputs.len(), 1); assert_eq!(session_start_inputs[0]["source"].as_str(), Some("startup")); assert_ne!( diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index fdf550b69010..76eb508083ba 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -43,11 +43,11 @@ pub struct SessionStartRequest { pub transcript_path: Option, pub model: String, pub permission_mode: String, - pub target: SessionStartTarget, + pub target: StartHookTarget, } #[derive(Debug, Clone)] -pub enum SessionStartTarget { +pub enum StartHookTarget { SessionStart { source: SessionStartSource, }, @@ -58,7 +58,7 @@ pub enum SessionStartTarget { }, } -impl SessionStartTarget { +impl StartHookTarget { fn event_name(&self) -> HookEventName { match self { Self::SessionStart { .. } => HookEventName::SessionStart, @@ -123,9 +123,9 @@ pub(crate) async fn run( }; } - let (input_json, turn_id, parse_completed) = match request.target { - SessionStartTarget::SessionStart { source } => { - match serde_json::to_string(&SessionStartCommandInput::new( + let (input_json, turn_id) = match request.target { + StartHookTarget::SessionStart { source } => { + let input_json = match serde_json::to_string(&SessionStartCommandInput::new( request.session_id.to_string(), request.transcript_path.clone(), request.cwd.display().to_string(), @@ -133,17 +133,7 @@ pub(crate) async fn run( request.permission_mode.clone(), source.as_str().to_string(), )) { - Ok(input_json) => ( - input_json, - turn_id, - parse_completed - as fn( - &ConfiguredHandler, - CommandRunResult, - Option, - ) - -> dispatcher::ParsedHandler, - ), + Ok(input_json) => input_json, Err(error) => { return serialization_failure_outcome( common::serialization_failure_hook_events( @@ -153,16 +143,17 @@ pub(crate) async fn run( ), ); } - } + }; + (input_json, turn_id) } - SessionStartTarget::SubagentStart { - turn_id, + StartHookTarget::SubagentStart { + turn_id: subagent_turn_id, agent_id, agent_type, } => { let input = SubagentStartCommandInput { session_id: request.session_id.to_string(), - turn_id: turn_id.clone(), + turn_id: subagent_turn_id.clone(), transcript_path: NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "SubagentStart".to_string(), @@ -171,28 +162,19 @@ pub(crate) async fn run( agent_id, agent_type, }; - match serde_json::to_string(&input) { - Ok(input_json) => ( - input_json, - Some(turn_id), - parse_subagent_start_completed - as fn( - &ConfiguredHandler, - CommandRunResult, - Option, - ) - -> dispatcher::ParsedHandler, - ), + let input_json = match serde_json::to_string(&input) { + Ok(input_json) => input_json, Err(error) => { return serialization_failure_outcome( common::serialization_failure_hook_events( matched, - Some(turn_id), + Some(subagent_turn_id), format!("failed to serialize subagent start hook input: {error}"), ), ); } - } + }; + (input_json, Some(subagent_turn_id)) } }; @@ -224,55 +206,15 @@ pub(crate) async fn run( } } +/// Interprets completed `SessionStart` and `SubagentStart` hook runs. +/// +/// The two events have different input payloads but share output handling: +/// hook JSON can emit warnings/context or stop the session being started, +/// invalid JSON-looking stdout fails, and plain stdout becomes model context. fn parse_completed( handler: &ConfiguredHandler, run_result: CommandRunResult, turn_id: Option, -) -> dispatcher::ParsedHandler { - parse_start_completed( - handler, - run_result, - turn_id, - "session start", - output_parser::parse_session_start, - StartHookSemantics { - allow_plain_text_context: true, - allow_stop: true, - }, - ) -} - -fn parse_subagent_start_completed( - handler: &ConfiguredHandler, - run_result: CommandRunResult, - turn_id: Option, -) -> dispatcher::ParsedHandler { - parse_start_completed( - handler, - run_result, - turn_id, - "subagent start", - output_parser::parse_subagent_start, - StartHookSemantics { - allow_plain_text_context: false, - allow_stop: false, - }, - ) -} - -#[derive(Clone, Copy)] -struct StartHookSemantics { - allow_plain_text_context: bool, - allow_stop: bool, -} - -fn parse_start_completed( - handler: &ConfiguredHandler, - run_result: CommandRunResult, - turn_id: Option, - hook_label: &str, - parse_output: fn(&str) -> Option, - semantics: StartHookSemantics, ) -> dispatcher::ParsedHandler { let mut entries = Vec::new(); let mut status = HookRunStatus::Completed; @@ -292,7 +234,17 @@ fn parse_start_completed( Some(0) => { let trimmed_stdout = run_result.stdout.trim(); if trimmed_stdout.is_empty() { - } else if let Some(parsed) = parse_output(&run_result.stdout) { + } else if let Some(parsed) = match handler.event_name { + HookEventName::SessionStart => { + output_parser::parse_session_start(&run_result.stdout) + } + HookEventName::SubagentStart => { + output_parser::parse_subagent_start(&run_result.stdout) + } + event_name => { + panic!("expected start hook event, got {event_name:?}") + } + } { if let Some(system_message) = parsed.universal.system_message { entries.push(HookOutputEntry { kind: HookOutputEntryKind::Warning, @@ -307,7 +259,7 @@ fn parse_start_completed( ); } let _ = parsed.universal.suppress_output; - if semantics.allow_stop && !parsed.universal.continue_processing { + if !parsed.universal.continue_processing { status = HookRunStatus::Stopped; should_stop = true; stop_reason = parsed.universal.stop_reason.clone(); @@ -318,14 +270,22 @@ fn parse_start_completed( }); } } - // Preserve plain-text context support without treating malformed JSON as context. - } else if !semantics.allow_plain_text_context - || output_parser::looks_like_json(&run_result.stdout) - { + } else if output_parser::looks_like_json(&run_result.stdout) { status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: format!("hook returned invalid {hook_label} JSON output"), + text: match handler.event_name { + HookEventName::SessionStart => { + "hook returned invalid session start JSON output" + } + HookEventName::SubagentStart => { + "hook returned invalid subagent start JSON output" + } + event_name => { + panic!("expected start hook event, got {event_name:?}") + } + } + .to_string(), }); } else { let additional_context = trimmed_stdout.to_string(); @@ -485,9 +445,77 @@ mod tests { ); } + #[test] + fn subagent_start_plain_stdout_becomes_model_context() { + let parsed = parse_completed( + &handler_for(HookEventName::SubagentStart), + run_result(Some(0), "hello from subagent hook\n", ""), + /*turn_id*/ Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + SessionStartHandlerData { + should_stop: false, + stop_reason: None, + additional_contexts_for_model: vec!["hello from subagent hook".to_string()], + } + ); + assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1")); + assert_eq!(parsed.completed.run.status, HookRunStatus::Completed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Context, + text: "hello from subagent hook".to_string(), + }] + ); + } + + #[test] + fn subagent_start_continue_false_stops_subagent() { + let parsed = parse_completed( + &handler_for(HookEventName::SubagentStart), + run_result( + Some(0), + r#"{"continue":false,"stopReason":"skip child","hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child context"}}"#, + "", + ), + /*turn_id*/ Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + SessionStartHandlerData { + should_stop: true, + stop_reason: Some("skip child".to_string()), + additional_contexts_for_model: vec!["child context".to_string()], + } + ); + assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1")); + assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped); + assert_eq!( + parsed.completed.run.entries, + vec![ + HookOutputEntry { + kind: HookOutputEntryKind::Context, + text: "child context".to_string(), + }, + HookOutputEntry { + kind: HookOutputEntryKind::Stop, + text: "skip child".to_string(), + }, + ] + ); + } + fn handler() -> ConfiguredHandler { + handler_for(HookEventName::SessionStart) + } + + fn handler_for(event_name: HookEventName) -> ConfiguredHandler { ConfiguredHandler { - event_name: HookEventName::SessionStart, + event_name, matcher: None, command: "echo hook".to_string(), timeout_sec: 600, diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 93bbe72ba544..5675d11e712c 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -56,7 +56,7 @@ pub use events::pre_tool_use::PreToolUseRequest; pub use events::session_start::SessionStartOutcome; pub use events::session_start::SessionStartRequest; pub use events::session_start::SessionStartSource; -pub use events::session_start::SessionStartTarget; +pub use events::session_start::StartHookTarget; pub use events::stop::StopOutcome; pub use events::stop::StopRequest; pub use events::user_prompt_submit::UserPromptSubmitOutcome; From 579bb3b0155300cd8223ce73dddc354a9309ddfe Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 16:10:26 -0700 Subject: [PATCH 06/10] Simplify stop hook parsing --- codex-rs/hooks/src/events/stop.rs | 105 ++++++++++++------------------ 1 file changed, 43 insertions(+), 62 deletions(-) diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index a7cf33e196c2..6afd36584d8c 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -78,6 +78,12 @@ struct StopHandlerData { continuation_fragments: Vec, } +#[derive(Debug, Clone, Copy)] +enum StopHookKind { + Stop, + SubagentStop, +} + pub(crate) fn preview( handlers: &[ConfiguredHandler], request: &StopRequest, @@ -113,7 +119,7 @@ pub(crate) async fn run( }; } - let (input_json, parse_completed) = match request.target { + let input_json = match request.target { StopHookTarget::Stop => { let input = StopCommandInput { session_id: request.session_id.to_string(), @@ -129,16 +135,7 @@ pub(crate) async fn run( ), }; match serde_json::to_string(&input) { - Ok(input_json) => ( - input_json, - parse_completed - as fn( - &ConfiguredHandler, - CommandRunResult, - Option, - ) - -> dispatcher::ParsedHandler, - ), + Ok(input_json) => input_json, Err(error) => { return serialization_failure_outcome( common::serialization_failure_hook_events( @@ -172,16 +169,7 @@ pub(crate) async fn run( ), }; match serde_json::to_string(&input) { - Ok(input_json) => ( - input_json, - parse_subagent_stop_completed - as fn( - &ConfiguredHandler, - CommandRunResult, - Option, - ) - -> dispatcher::ParsedHandler, - ), + Ok(input_json) => input_json, Err(error) => { return serialization_failure_outcome( common::serialization_failure_hook_events( @@ -221,39 +209,6 @@ fn parse_completed( handler: &ConfiguredHandler, run_result: CommandRunResult, turn_id: Option, -) -> dispatcher::ParsedHandler { - parse_stop_completed( - handler, - run_result, - turn_id, - "Stop", - "stop", - output_parser::parse_stop, - ) -} - -fn parse_subagent_stop_completed( - handler: &ConfiguredHandler, - run_result: CommandRunResult, - turn_id: Option, -) -> dispatcher::ParsedHandler { - parse_stop_completed( - handler, - run_result, - turn_id, - "SubagentStop", - "subagent stop", - output_parser::parse_subagent_stop, - ) -} - -fn parse_stop_completed( - handler: &ConfiguredHandler, - run_result: CommandRunResult, - turn_id: Option, - hook_name: &str, - hook_label: &str, - parse_output: fn(&str) -> Option, ) -> dispatcher::ParsedHandler { let mut entries = Vec::new(); let mut status = HookRunStatus::Completed; @@ -262,6 +217,13 @@ fn parse_stop_completed( let mut should_block = false; let mut block_reason = None; let mut continuation_prompt = None; + let hook_kind = match handler.event_name { + HookEventName::Stop => StopHookKind::Stop, + HookEventName::SubagentStop => StopHookKind::SubagentStop, + event_name => { + panic!("expected stop hook event, got {event_name:?}"); + } + }; match run_result.error.as_deref() { Some(error) => { @@ -275,7 +237,12 @@ fn parse_stop_completed( Some(0) => { let trimmed_stdout = run_result.stdout.trim(); if trimmed_stdout.is_empty() { - } else if let Some(parsed) = parse_output(&run_result.stdout) { + } else if let Some(parsed) = match hook_kind { + StopHookKind::Stop => output_parser::parse_stop(&run_result.stdout), + StopHookKind::SubagentStop => { + output_parser::parse_subagent_stop(&run_result.stdout) + } + } { if let Some(system_message) = parsed.universal.system_message { entries.push(HookOutputEntry { kind: HookOutputEntryKind::Warning, @@ -315,9 +282,11 @@ fn parse_stop_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: format!( - "{hook_name} hook returned decision:block without a non-empty reason" - ), + text: match hook_kind { + StopHookKind::Stop => "Stop hook returned decision:block without a non-empty reason", + StopHookKind::SubagentStop => "SubagentStop hook returned decision:block without a non-empty reason", + } + .to_string(), }); } } @@ -325,7 +294,13 @@ fn parse_stop_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: format!("hook returned invalid {hook_label} hook JSON output"), + text: match hook_kind { + StopHookKind::Stop => "hook returned invalid stop hook JSON output", + StopHookKind::SubagentStop => { + "hook returned invalid subagent stop hook JSON output" + } + } + .to_string(), }); } } @@ -343,9 +318,15 @@ fn parse_stop_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: format!( - "{hook_name} hook exited with code 2 but did not write a continuation prompt to stderr" - ), + text: match hook_kind { + StopHookKind::Stop => { + "Stop hook exited with code 2 but did not write a continuation prompt to stderr" + } + StopHookKind::SubagentStop => { + "SubagentStop hook exited with code 2 but did not write a continuation prompt to stderr" + } + } + .to_string(), }); } } From ce0d7c465cf845b6ccd04e9a061d956869d8215e Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 16:28:08 -0700 Subject: [PATCH 07/10] Remove stop hook kind helper --- codex-rs/hooks/src/events/stop.rs | 39 ++++++++++++++----------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 6afd36584d8c..24920c569b7c 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -78,12 +78,6 @@ struct StopHandlerData { continuation_fragments: Vec, } -#[derive(Debug, Clone, Copy)] -enum StopHookKind { - Stop, - SubagentStop, -} - pub(crate) fn preview( handlers: &[ConfiguredHandler], request: &StopRequest, @@ -217,9 +211,8 @@ fn parse_completed( let mut should_block = false; let mut block_reason = None; let mut continuation_prompt = None; - let hook_kind = match handler.event_name { - HookEventName::Stop => StopHookKind::Stop, - HookEventName::SubagentStop => StopHookKind::SubagentStop, + let hook_event_name = match handler.event_name { + HookEventName::Stop | HookEventName::SubagentStop => handler.event_name, event_name => { panic!("expected stop hook event, got {event_name:?}"); } @@ -237,11 +230,12 @@ fn parse_completed( Some(0) => { let trimmed_stdout = run_result.stdout.trim(); if trimmed_stdout.is_empty() { - } else if let Some(parsed) = match hook_kind { - StopHookKind::Stop => output_parser::parse_stop(&run_result.stdout), - StopHookKind::SubagentStop => { + } else if let Some(parsed) = match hook_event_name { + HookEventName::Stop => output_parser::parse_stop(&run_result.stdout), + HookEventName::SubagentStop => { output_parser::parse_subagent_stop(&run_result.stdout) } + _ => unreachable!("validated stop hook event"), } { if let Some(system_message) = parsed.universal.system_message { entries.push(HookOutputEntry { @@ -282,9 +276,10 @@ fn parse_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: match hook_kind { - StopHookKind::Stop => "Stop hook returned decision:block without a non-empty reason", - StopHookKind::SubagentStop => "SubagentStop hook returned decision:block without a non-empty reason", + text: match hook_event_name { + HookEventName::Stop => "Stop hook returned decision:block without a non-empty reason", + HookEventName::SubagentStop => "SubagentStop hook returned decision:block without a non-empty reason", + _ => unreachable!("validated stop hook event"), } .to_string(), }); @@ -294,11 +289,12 @@ fn parse_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: match hook_kind { - StopHookKind::Stop => "hook returned invalid stop hook JSON output", - StopHookKind::SubagentStop => { + text: match hook_event_name { + HookEventName::Stop => "hook returned invalid stop hook JSON output", + HookEventName::SubagentStop => { "hook returned invalid subagent stop hook JSON output" } + _ => unreachable!("validated stop hook event"), } .to_string(), }); @@ -318,13 +314,14 @@ fn parse_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: match hook_kind { - StopHookKind::Stop => { + text: match hook_event_name { + HookEventName::Stop => { "Stop hook exited with code 2 but did not write a continuation prompt to stderr" } - StopHookKind::SubagentStop => { + HookEventName::SubagentStop => { "SubagentStop hook exited with code 2 but did not write a continuation prompt to stderr" } + _ => unreachable!("validated stop hook event"), } .to_string(), }); From 94cbf56ad096a229884a51f7bde7063a8f167c91 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 16:33:06 -0700 Subject: [PATCH 08/10] Clarify stop hook target resolution --- codex-rs/core/src/hook_runtime.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 7be05d060dad..926dc94213d8 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -308,6 +308,8 @@ pub(crate) async fn run_turn_stop_hooks( stop_hook_active: bool, last_assistant_message: Option, ) -> StopOutcome { + // Resolve the stop hook kind from the session source before building the + // request. Root turns run Stop; thread-spawned child turns run SubagentStop. let (target, transcript_path) = match &turn_context.session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, @@ -347,6 +349,8 @@ pub(crate) async fn run_turn_stop_hooks( parent_transcript_path, ) } + // Internal/synthetic subagents do not expose user-configured lifecycle + // hooks, so there is no Stop or SubagentStop request to dispatch. SessionSource::SubAgent(_) => return StopOutcome::default(), _ => (StopHookTarget::Stop, sess.hook_transcript_path().await), }; From 08f7c920dd9db9ffb7f3c942ba36f56d2cbe58b4 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 20 May 2026 15:43:55 -0700 Subject: [PATCH 09/10] Fix subagent hook fixture arg comments --- codex-rs/core/tests/suite/subagent_notifications.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index d043fc7bc401..683f92fb652e 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -526,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}"); } }) @@ -672,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}"); } }) From 0ed016d8c99f34a13233a24f630299d1944cc6d8 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 20 May 2026 16:03:02 -0700 Subject: [PATCH 10/10] Fix hooks schema arg comments --- codex-rs/hooks/src/schema.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index ab9fcbe6e3dc..3f17986dec27 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -1053,7 +1053,7 @@ mod tests { turn_id: "turn-1".to_string(), agent_id: subagent.agent_id, agent_type: subagent.agent_type, - transcript_path: NullableString::from_path(None), + transcript_path: NullableString::from_path(/*path*/ None), cwd: "/tmp".to_string(), hook_event_name: "PreToolUse".to_string(), model: "gpt-test".to_string(), @@ -1086,7 +1086,7 @@ mod tests { turn_id: "turn-1".to_string(), agent_id: None, agent_type: None, - transcript_path: NullableString::from_path(None), + transcript_path: NullableString::from_path(/*path*/ None), cwd: "/tmp".to_string(), hook_event_name: "PreToolUse".to_string(), model: "gpt-test".to_string(),