mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add SubagentStart hook (#22782)
# What `SubagentStart` runs once when Codex creates a thread-spawned subagent, before that child sends its first model request. Thread-spawned subagents use `SubagentStart` instead of the normal root-agent `SessionStart` hook. Configured handlers match on the subagent `agent_type`, using the same value passed to `spawn_agent`. When no agent type is specified, Codex uses the default agent type. Hook input includes the normal session-start fields plus: - `agent_id`: the child thread id. - `agent_type`: the resolved subagent type. `SubagentStart` may return `hookSpecificOutput.additionalContext`. That context is added to the child conversation before the first model request. # Lifecycle Scope Only thread-spawned subagents run `SubagentStart`. Internal/system subagents such as Review, Compact, MemoryConsolidation, and Other do not run normal `SessionStart` hooks and do not run `SubagentStart`. This avoids exposing synthetic matcher labels for internal implementation paths. Also the `SessionStart` hook no longer fires for subagents, this matches behavior with other coding agents' implementation # Stack 1. This PR: add `SubagentStart`. 2. #22873: add `SubagentStop`. 3. #22882: add subagent identity to normal hook inputs.
This commit is contained in:
committed by
GitHub
Unverified
parent
d269aa2af9
commit
d661ab70ed
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1740,6 +1740,7 @@
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"stop"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
+8
@@ -9624,6 +9624,7 @@
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"stop"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -10514,6 +10515,12 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"SubagentStart": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"UserPromptSubmit": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
|
||||
@@ -10541,6 +10548,7 @@
|
||||
"PreToolUse",
|
||||
"SessionStart",
|
||||
"Stop",
|
||||
"SubagentStart",
|
||||
"UserPromptSubmit"
|
||||
],
|
||||
"type": "object"
|
||||
|
||||
+8
@@ -6124,6 +6124,7 @@
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"stop"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -7063,6 +7064,12 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"SubagentStart": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"UserPromptSubmit": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
@@ -7090,6 +7097,7 @@
|
||||
"PreToolUse",
|
||||
"SessionStart",
|
||||
"Stop",
|
||||
"SubagentStart",
|
||||
"UserPromptSubmit"
|
||||
],
|
||||
"type": "object"
|
||||
|
||||
+7
@@ -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"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"stop"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"stop"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"stop"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"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" | "stop";
|
||||
export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "stop";
|
||||
|
||||
+1
-1
@@ -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>, 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>, Stop: Array<ConfiguredHookMatcherGroup>, };
|
||||
|
||||
@@ -423,6 +423,9 @@ pub struct ManagedHooksRequirements {
|
||||
#[serde(rename = "UserPromptSubmit")]
|
||||
#[ts(rename = "UserPromptSubmit")]
|
||||
pub user_prompt_submit: Vec<ConfiguredHookMatcherGroup>,
|
||||
#[serde(rename = "SubagentStart")]
|
||||
#[ts(rename = "SubagentStart")]
|
||||
pub subagent_start: 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, Stop
|
||||
PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, SubagentStart, Stop
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -454,6 +454,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
|
||||
post_compact,
|
||||
session_start,
|
||||
user_prompt_submit,
|
||||
subagent_start,
|
||||
stop,
|
||||
} = hooks;
|
||||
|
||||
@@ -467,6 +468,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ pub struct HookEventsToml {
|
||||
pub session_start: Vec<MatcherGroup>,
|
||||
#[serde(rename = "UserPromptSubmit", default)]
|
||||
pub user_prompt_submit: Vec<MatcherGroup>,
|
||||
#[serde(rename = "SubagentStart", default)]
|
||||
pub subagent_start: Vec<MatcherGroup>,
|
||||
#[serde(rename = "Stop", default)]
|
||||
pub stop: Vec<MatcherGroup>,
|
||||
}
|
||||
@@ -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<MatcherGroup>); 8] {
|
||||
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1122,6 +1122,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"SubagentStart": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/MatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"UserPromptSubmit": {
|
||||
"default": [],
|
||||
"items": {
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_hooks::PostToolUseRequest;
|
||||
use codex_hooks::PreToolUseOutcome;
|
||||
use codex_hooks::PreToolUseRequest;
|
||||
use codex_hooks::SessionStartOutcome;
|
||||
use codex_hooks::StartHookTarget;
|
||||
use codex_hooks::StopOutcome;
|
||||
use codex_hooks::StopRequest;
|
||||
use codex_hooks::UserPromptSubmitOutcome;
|
||||
@@ -30,6 +31,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 serde_json::Value;
|
||||
|
||||
use crate::context::ContextualUserFragment;
|
||||
@@ -100,6 +103,30 @@ 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!(
|
||||
session_start_source,
|
||||
codex_hooks::SessionStartSource::Startup
|
||||
) =>
|
||||
{
|
||||
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: sess.thread_id().to_string(),
|
||||
agent_type,
|
||||
}
|
||||
}
|
||||
SessionSource::SubAgent(_) => return false,
|
||||
_ => StartHookTarget::SessionStart {
|
||||
source: session_start_source,
|
||||
},
|
||||
};
|
||||
let request = codex_hooks::SessionStartRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
#[allow(deprecated)]
|
||||
@@ -107,7 +134,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);
|
||||
@@ -607,6 +634,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 {
|
||||
|
||||
@@ -530,7 +530,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::StartHookTarget::SessionStart {
|
||||
source: codex_hooks::SessionStartSource::Startup,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1296,7 +1298,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::StartHookTarget::SessionStart {
|
||||
source: codex_hooks::SessionStartSource::Startup,
|
||||
},
|
||||
};
|
||||
assert!(session.hooks().preview_session_start(&request).is_empty());
|
||||
|
||||
@@ -1401,7 +1405,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::StartHookTarget::SessionStart {
|
||||
source: codex_hooks::SessionStartSource::Startup,
|
||||
},
|
||||
};
|
||||
assert!(session.hooks().preview_session_start(&request).is_empty());
|
||||
|
||||
|
||||
@@ -42,6 +42,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
|
||||
@@ -100,6 +101,96 @@ 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<Vec<serde_json::Value>> {
|
||||
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_hook_log(
|
||||
home: &Path,
|
||||
filename: &str,
|
||||
expected_len: usize,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
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<String> {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
@@ -279,6 +370,110 @@ 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_with_namespace(
|
||||
SPAWN_CALL_ID,
|
||||
MULTI_AGENT_V1_NAMESPACE,
|
||||
"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, "<subagent_notification>")
|
||||
&& !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 = 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?;
|
||||
assert_eq!(
|
||||
start_inputs[0]["agent_id"].as_str(),
|
||||
Some(spawned_id.as_str())
|
||||
);
|
||||
|
||||
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!(
|
||||
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(()));
|
||||
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"SubagentStart",
|
||||
"Stop"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"SubagentStart",
|
||||
"Stop"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"SubagentStart",
|
||||
"Stop"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"SubagentStart",
|
||||
"Stop"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
+1
@@ -17,6 +17,7 @@
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"SubagentStart",
|
||||
"Stop"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SessionStartOutput> {
|
||||
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<SessionStartOutput> {
|
||||
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<crate::schema::SessionStartHookSpecificOutputWire>,
|
||||
) -> 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<PreToolUseOutput> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
pub model: String,
|
||||
pub permission_mode: String,
|
||||
pub source: SessionStartSource,
|
||||
pub target: StartHookTarget,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StartHookTarget {
|
||||
SessionStart {
|
||||
source: SessionStartSource,
|
||||
},
|
||||
SubagentStart {
|
||||
turn_id: String,
|
||||
agent_id: String,
|
||||
agent_type: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl StartHookTarget {
|
||||
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<HookRunSummary> {
|
||||
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,58 @@ 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) = 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(),
|
||||
request.model.clone(),
|
||||
request.permission_mode.clone(),
|
||||
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}"),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
(input_json, 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: 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(),
|
||||
model: request.model.clone(),
|
||||
permission_mode: request.permission_mode.clone(),
|
||||
agent_id,
|
||||
agent_type,
|
||||
};
|
||||
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(subagent_turn_id),
|
||||
format!("failed to serialize subagent start hook input: {error}"),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
(input_json, Some(subagent_turn_id))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,6 +206,12 @@ pub(crate) async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
/// Interprets completed `SessionStart` and `SubagentStart` hook runs.
|
||||
///
|
||||
/// The two events have different input payloads but share most output
|
||||
/// handling: hook JSON can emit warnings/context, invalid JSON-looking stdout
|
||||
/// fails, and plain stdout becomes model context. Only `SessionStart` honors
|
||||
/// `continue:false`; `SubagentStart` stays context-injection-only.
|
||||
fn parse_completed(
|
||||
handler: &ConfiguredHandler,
|
||||
run_result: CommandRunResult,
|
||||
@@ -162,8 +235,17 @@ 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) = 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,
|
||||
@@ -178,7 +260,9 @@ fn parse_completed(
|
||||
);
|
||||
}
|
||||
let _ = parsed.universal.suppress_output;
|
||||
if !parsed.universal.continue_processing {
|
||||
if handler.event_name == HookEventName::SessionStart
|
||||
&& !parsed.universal.continue_processing
|
||||
{
|
||||
status = HookRunStatus::Stopped;
|
||||
should_stop = true;
|
||||
stop_reason = parsed.universal.stop_reason.clone();
|
||||
@@ -189,12 +273,22 @@ fn parse_completed(
|
||||
});
|
||||
}
|
||||
}
|
||||
// Preserve plain-text context support without treating malformed JSON as context.
|
||||
} else if 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: 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();
|
||||
@@ -354,9 +448,71 @@ 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_is_ignored() {
|
||||
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: false,
|
||||
stop_reason: None,
|
||||
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::Completed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Context,
|
||||
text: "child context".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,
|
||||
|
||||
@@ -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::StartHookTarget;
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<SessionStartHookSpecificOutputWire>,
|
||||
}
|
||||
|
||||
#[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::<UserPromptSubmitCommandOutputWire>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(SUBAGENT_START_INPUT_FIXTURE),
|
||||
schema_json::<SubagentStartCommandInput>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(SUBAGENT_START_OUTPUT_FIXTURE),
|
||||
schema_json::<SubagentStartCommandOutputWire>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(STOP_INPUT_FIXTURE),
|
||||
schema_json::<StopCommandInput>()?,
|
||||
@@ -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::<SubagentStartCommandInput>()
|
||||
.expect("serialize subagent start input schema"),
|
||||
)
|
||||
.expect("parse subagent start input schema");
|
||||
let stop: Value = serde_json::from_slice(
|
||||
&schema_json::<StopCommandInput>().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");
|
||||
|
||||
@@ -1337,6 +1337,7 @@ pub enum HookEventName {
|
||||
PostCompact,
|
||||
SessionStart,
|
||||
UserPromptSubmit,
|
||||
SubagentStart,
|
||||
Stop,
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
@@ -1571,6 +1571,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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user