Add SubagentStop hook (#22873)

# What

<img width="1792" height="1024" alt="image"
src="https://github.com/user-attachments/assets/8f81d232-5813-4994-a61d-e42a05a93a3e"
/>

`SubagentStop` runs when a thread-spawned subagent turn is about to
finish. Thread-spawned subagents use `SubagentStop` instead of the
normal root-agent `Stop` hook.

Configured handlers match on `agent_type`. Hook input includes the
normal stop fields plus:

- `agent_id`: the child thread id.
- `agent_type`: the resolved subagent type.
- `agent_transcript_path`: the child subagent transcript path.
- `transcript_path`: the parent thread transcript path.
- `last_assistant_message`: the final assistant message from the child
turn, when available.
- `stop_hook_active`: `true` when the child is already continuing
because an earlier stop-like hook blocked completion.

`SubagentStop` shares the same completion-control semantics as `Stop`,
scoped to the child turn:

- No decision allows the child turn to finish.
- `decision: "block"` with a non-empty `reason` records that reason as
hook feedback and continues the child with that prompt.
- `continue: false` stops the child turn. If `stopReason` is present,
Codex surfaces it as the stop reason.

# Lifecycle Scope

Only thread-spawned subagents run `SubagentStop`.

Internal/system subagents such as Review, Compact, MemoryConsolidation,
and Other do not run normal `Stop` hooks and do not run `SubagentStop`.
This avoids exposing synthetic matcher labels for internal
implementation paths.

# Stack

1. #22782: add `SubagentStart`.
2. This PR: add `SubagentStop`.
3. #22882: add subagent identity to normal hook inputs.
This commit is contained in:
Abhinav
2026-05-20 14:59:41 -07:00
committed by GitHub
Unverified
parent 40ad7be2b5
commit eee3e60db3
43 changed files with 813 additions and 77 deletions
+1
View File
@@ -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",
}
}
@@ -1836,6 +1836,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -9710,6 +9710,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -10606,6 +10607,12 @@
},
"type": "array"
},
"SubagentStop": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
@@ -10634,6 +10641,7 @@
"SessionStart",
"Stop",
"SubagentStart",
"SubagentStop",
"UserPromptSubmit"
],
"type": "object"
@@ -6190,6 +6190,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -7135,6 +7136,12 @@
},
"type": "array"
},
"SubagentStop": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -7163,6 +7170,7 @@
"SessionStart",
"Stop",
"SubagentStart",
"SubagentStop",
"UserPromptSubmit"
],
"type": "object"
@@ -288,6 +288,12 @@
},
"type": "array"
},
"SubagentStop": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -316,6 +322,7 @@
"SessionStart",
"Stop",
"SubagentStart",
"SubagentStop",
"UserPromptSubmit"
],
"type": "object"
@@ -15,6 +15,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -15,6 +15,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -30,6 +30,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -47,6 +47,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -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";
@@ -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<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, };
export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, };
@@ -434,6 +434,9 @@ pub struct ManagedHooksRequirements {
#[serde(rename = "SubagentStart")]
#[ts(rename = "SubagentStart")]
pub subagent_start: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "SubagentStop")]
#[ts(rename = "SubagentStop")]
pub subagent_stop: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "Stop")]
#[ts(rename = "Stop")]
pub stop: Vec<ConfiguredHookMatcherGroup>,
@@ -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
}
);
@@ -467,6 +467,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
session_start,
user_prompt_submit,
subagent_start,
subagent_stop,
stop,
} = hooks;
@@ -481,6 +482,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),
}
}
+8 -1
View File
@@ -47,6 +47,8 @@ pub struct HookEventsToml {
pub user_prompt_submit: Vec<MatcherGroup>,
#[serde(rename = "SubagentStart", default)]
pub subagent_start: Vec<MatcherGroup>,
#[serde(rename = "SubagentStop", default)]
pub subagent_stop: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<MatcherGroup>,
}
@@ -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<MatcherGroup>); 9] {
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 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),
]
}
+7
View File
@@ -1129,6 +1129,13 @@
},
"type": "array"
},
"SubagentStop": {
"default": [],
"items": {
"$ref": "#/definitions/MatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"default": [],
"items": {
+75 -25
View File
@@ -14,8 +14,8 @@ use codex_hooks::PreToolUseOutcome;
use codex_hooks::PreToolUseRequest;
use codex_hooks::SessionStartOutcome;
use codex_hooks::StartHookTarget;
use codex_hooks::StopHookTarget;
use codex_hooks::StopOutcome;
use codex_hooks::StopRequest;
use codex_hooks::UserPromptSubmitOutcome;
use codex_hooks::UserPromptSubmitRequest;
use codex_otel::HOOK_RUN_DURATION_METRIC;
@@ -33,6 +33,7 @@ use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::HookStartedEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_thread_store::ReadThreadParams;
use serde_json::Value;
use crate::context::ContextualUserFragment;
@@ -288,6 +289,78 @@ pub(crate) async fn run_post_tool_use_hooks(
outcome
}
pub(crate) async fn run_turn_stop_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
stop_hook_active: bool,
last_assistant_message: Option<String>,
) -> 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,
parent_thread_id,
..
}) => {
let agent_type = agent_role
.clone()
.unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string());
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: sess.thread_id().to_string(),
agent_type,
agent_transcript_path,
},
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),
};
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<Session>,
turn_context: &Arc<TurnContext>,
@@ -352,30 +425,6 @@ pub(crate) async fn run_post_compact_hooks(
}
}
pub(crate) async fn run_stop_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
stop_hook_active: bool,
last_assistant_message: Option<String>,
) -> StopOutcome {
let request = 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: hook_permission_mode(turn_context),
stop_hook_active,
last_assistant_message,
};
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_legacy_after_agent_hook(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -638,6 +687,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 {
+2 -2
View File
@@ -23,7 +23,7 @@ use crate::hook_runtime::record_additional_contexts;
use crate::hook_runtime::record_pending_input;
use crate::hook_runtime::run_legacy_after_agent_hook;
use crate::hook_runtime::run_pending_session_start_hooks;
use crate::hook_runtime::run_stop_hooks;
use crate::hook_runtime::run_turn_stop_hooks;
use crate::injection::ToolMentionKind;
use crate::injection::app_id_from_path;
use crate::injection::tool_kind_for_path;
@@ -360,7 +360,7 @@ pub(crate) async fn run_turn(
if !needs_follow_up {
last_agent_message = sampling_request_last_agent_message;
let stop_outcome = run_stop_hooks(
let stop_outcome = run_turn_stop_hooks(
&sess,
&turn_context,
stop_hook_active,
@@ -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;
@@ -20,6 +30,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::Value;
use serde_json::json;
@@ -43,6 +55,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
@@ -101,7 +115,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!(
@@ -133,6 +151,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": [{
@@ -148,12 +211,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(())
}
@@ -426,12 +504,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)
@@ -474,6 +552,210 @@ 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_with_namespace(
SPAWN_CALL_ID,
MULTI_AGENT_V1_NAMESPACE,
"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(
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<_>>(),
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::UserInput {
items: vec![UserInput::Text {
text: INTERNAL_SUBAGENT_PROMPT.to_string(),
text_elements: Vec::new(),
}],
environments: None,
final_output_json_schema: None,
responsesapi_client_metadata: None,
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
cwd: Some(test.config.cwd.to_path_buf()),
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(sandbox_policy),
permission_profile,
model: Some(internal_thread.session_configured.model.clone()),
..Default::default()
},
})
.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(()));
@@ -12,6 +12,7 @@
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"
@@ -18,6 +18,7 @@
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"
@@ -12,6 +12,7 @@
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"
@@ -12,6 +12,7 @@
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"
@@ -12,6 +12,7 @@
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"
@@ -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"
}
@@ -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"
}
@@ -18,6 +18,7 @@
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"
+2
View File
@@ -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,
}
}
+1
View File
@@ -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",
}
}
+32 -7
View File
@@ -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<SessionStartOutput> {
@@ -280,22 +281,46 @@ pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option<UserPromptSubmitO
pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
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<StopOutput> {
let wire: SubagentStopCommandOutputWire = parse_json(stdout)?;
Some(stop_output(
wire.universal,
wire.decision,
wire.reason,
"SubagentStop",
))
}
fn stop_output(
universal: HookUniversalOutputWire,
decision: Option<BlockDecisionWire>,
reason: Option<String>,
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<HookUniversalOutputWire> for UniversalOutput {
@@ -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");
+1
View File
@@ -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,
+141 -34
View File
@@ -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<String>,
pub target: StopHookTarget,
}
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum StopHookTarget {
Stop,
SubagentStop {
agent_id: String,
agent_type: String,
agent_transcript_path: Option<PathBuf>,
},
}
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<HookCompletedEvent>,
pub should_stop: bool,
@@ -52,12 +80,16 @@ struct StopHandlerData {
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
_request: &StopRequest,
request: &StopRequest,
) -> Vec<HookRunSummary> {
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,67 @@ 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 = 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,
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,
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}"),
),
);
}
}
}
};
@@ -133,6 +211,12 @@ fn parse_completed(
let mut should_block = false;
let mut block_reason = None;
let mut continuation_prompt = None;
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:?}");
}
};
match run_result.error.as_deref() {
Some(error) => {
@@ -146,7 +230,13 @@ 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) = 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 {
kind: HookOutputEntryKind::Warning,
@@ -186,9 +276,12 @@ 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: 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(),
});
}
}
@@ -196,7 +289,14 @@ fn parse_completed(
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid stop hook JSON output".to_string(),
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(),
});
}
}
@@ -214,9 +314,16 @@ 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: match hook_event_name {
HookEventName::Stop => {
"Stop hook exited with code 2 but did not write a continuation prompt to stderr"
}
HookEventName::SubagentStop => {
"SubagentStop hook exited with code 2 but did not write a continuation prompt to stderr"
}
_ => unreachable!("validated stop hook event"),
}
.to_string(),
});
}
}
+6 -2
View File
@@ -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::StartHookTarget;
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",
}
}
+69
View File
@@ -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<String>,
}
#[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<BlockDecisionWire>,
/// 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<String>,
}
#[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::<SubagentStartCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(SUBAGENT_STOP_INPUT_FIXTURE),
schema_json::<SubagentStopCommandInput>()?,
)?;
write_schema(
&generated_dir.join(SUBAGENT_STOP_OUTPUT_FIXTURE),
schema_json::<SubagentStopCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(STOP_INPUT_FIXTURE),
schema_json::<StopCommandInput>()?,
@@ -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::<SubagentStopCommandInput>()
.expect("serialize subagent stop input schema"),
)
.expect("parse subagent stop input schema");
let stop: Value = serde_json::from_slice(
&schema_json::<StopCommandInput>().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");
+1
View File
@@ -1338,6 +1338,7 @@ pub enum HookEventName {
SessionStart,
UserPromptSubmit,
SubagentStart,
SubagentStop,
Stop,
}
@@ -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",
}
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1572,6 +1572,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",
}
}
@@ -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",
}
}