Skip to main content

codex_hooks/events/
common.rs

1use codex_protocol::protocol::HookCompletedEvent;
2use codex_protocol::protocol::HookEventName;
3use codex_protocol::protocol::HookOutputEntry;
4use codex_protocol::protocol::HookOutputEntryKind;
5use codex_protocol::protocol::HookRunStatus;
6use codex_protocol::protocol::HookRunSummary;
7
8use crate::engine::ConfiguredHandler;
9use crate::engine::dispatcher;
10use crate::output_spill::AdditionalContext;
11
12/// Identifies a thread-spawned subagent when a normal hook runs inside it.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct SubagentHookContext {
15    pub agent_id: String,
16    pub agent_type: String,
17}
18
19pub(crate) fn join_text_chunks(chunks: Vec<String>) -> Option<String> {
20    if chunks.is_empty() {
21        None
22    } else {
23        Some(chunks.join("\n\n"))
24    }
25}
26
27pub(crate) fn trimmed_non_empty(text: &str) -> Option<String> {
28    let trimmed = text.trim();
29    if trimmed.is_empty() {
30        None
31    } else {
32        Some(trimmed.to_string())
33    }
34}
35
36pub(crate) fn append_additional_context(
37    entries: &mut Vec<HookOutputEntry>,
38    additional_contexts_for_model: &mut Vec<AdditionalContext>,
39    handler: &ConfiguredHandler,
40    additional_context: String,
41) {
42    entries.push(HookOutputEntry {
43        kind: HookOutputEntryKind::Context,
44        text: additional_context.clone(),
45    });
46    additional_contexts_for_model.push(AdditionalContext {
47        text: additional_context,
48        limit: handler.additional_context_limit,
49    });
50}
51
52pub(crate) fn flatten_additional_contexts<'a>(
53    additional_contexts: impl IntoIterator<Item = &'a [AdditionalContext]>,
54) -> Vec<AdditionalContext> {
55    additional_contexts
56        .into_iter()
57        .flat_map(|chunk| chunk.iter().cloned())
58        .collect()
59}
60
61pub(crate) fn serialization_failure_hook_events(
62    handlers: Vec<ConfiguredHandler>,
63    turn_id: Option<String>,
64    error_message: String,
65) -> Vec<HookCompletedEvent> {
66    handlers
67        .into_iter()
68        .map(|handler| {
69            let mut run = dispatcher::running_summary(&handler);
70            run.status = HookRunStatus::Failed;
71            run.completed_at = Some(run.started_at);
72            run.duration_ms = Some(0);
73            run.entries = vec![HookOutputEntry {
74                kind: HookOutputEntryKind::Error,
75                text: error_message.clone(),
76            }];
77            HookCompletedEvent {
78                turn_id: turn_id.clone(),
79                run,
80            }
81        })
82        .collect()
83}
84
85pub(crate) fn serialization_failure_hook_events_for_tool_use(
86    handlers: Vec<ConfiguredHandler>,
87    turn_id: Option<String>,
88    error_message: String,
89    tool_use_id: &str,
90) -> Vec<HookCompletedEvent> {
91    serialization_failure_hook_events(handlers, turn_id, error_message)
92        .into_iter()
93        .map(|event| hook_completed_for_tool_use(event, tool_use_id))
94        .collect()
95}
96
97pub(crate) fn hook_completed_for_tool_use(
98    mut event: HookCompletedEvent,
99    tool_use_id: &str,
100) -> HookCompletedEvent {
101    event.run = hook_run_for_tool_use(event.run, tool_use_id);
102    event
103}
104
105pub(crate) fn hook_run_for_tool_use(mut run: HookRunSummary, tool_use_id: &str) -> HookRunSummary {
106    run.id = format!("{}:{tool_use_id}", run.id);
107    run
108}
109
110pub(crate) fn matcher_pattern_for_event(
111    event_name: HookEventName,
112    matcher: Option<&str>,
113) -> Option<&str> {
114    match event_name {
115        HookEventName::PreToolUse
116        | HookEventName::PermissionRequest
117        | HookEventName::PostToolUse
118        | HookEventName::SessionStart
119        | HookEventName::SessionEnd
120        | HookEventName::SubagentStart
121        | HookEventName::SubagentStop
122        | HookEventName::PreCompact
123        | HookEventName::PostCompact => matcher,
124        HookEventName::UserPromptSubmit | HookEventName::Stop => None,
125    }
126}
127
128pub(crate) fn validate_matcher_pattern(matcher: &str) -> Result<(), regex::Error> {
129    if is_match_all_matcher(matcher) || is_exact_matcher(matcher) {
130        return Ok(());
131    }
132    regex::Regex::new(matcher).map(|_| ())
133}
134
135pub(crate) fn matches_matcher(matcher: Option<&str>, input: Option<&str>) -> bool {
136    match matcher {
137        None => true,
138        Some(matcher) if is_match_all_matcher(matcher) => true,
139        Some(matcher) if is_exact_matcher(matcher) => input
140            .map(|input| matcher.split('|').any(|candidate| candidate == input))
141            .unwrap_or(false),
142        Some(matcher) => input
143            .and_then(|input| {
144                regex::Regex::new(matcher)
145                    .ok()
146                    .map(|regex| regex.is_match(input))
147            })
148            .unwrap_or(false),
149    }
150}
151
152pub(crate) fn matcher_inputs<'a>(
153    tool_name: &'a str,
154    matcher_aliases: &'a [String],
155) -> Vec<&'a str> {
156    // Keep the canonical name first so matcher previews and execution preserve
157    // the same primary identity that hook stdin will serialize.
158    std::iter::once(tool_name)
159        .chain(matcher_aliases.iter().map(String::as_str))
160        .collect()
161}
162
163fn is_match_all_matcher(matcher: &str) -> bool {
164    matcher.is_empty() || matcher == "*"
165}
166
167fn is_exact_matcher(matcher: &str) -> bool {
168    matcher
169        .chars()
170        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '|')
171}
172
173#[cfg(test)]
174mod tests {
175    use codex_protocol::protocol::HookEventName;
176    use pretty_assertions::assert_eq;
177
178    use super::matcher_pattern_for_event;
179    use super::matches_matcher;
180    use super::validate_matcher_pattern;
181
182    #[test]
183    fn matcher_omitted_matches_all_occurrences() {
184        assert!(matches_matcher(/*matcher*/ None, Some("Bash")));
185        assert!(matches_matcher(/*matcher*/ None, Some("Write")));
186    }
187
188    #[test]
189    fn matcher_star_matches_all_occurrences() {
190        assert!(matches_matcher(Some("*"), Some("Bash")));
191        assert!(matches_matcher(Some("*"), Some("Edit")));
192        assert_eq!(validate_matcher_pattern("*"), Ok(()));
193    }
194
195    #[test]
196    fn matcher_empty_string_matches_all_occurrences() {
197        assert!(matches_matcher(Some(""), Some("Bash")));
198        assert!(matches_matcher(Some(""), Some("SessionStart")));
199        assert_eq!(validate_matcher_pattern(""), Ok(()));
200    }
201
202    #[test]
203    fn exact_matcher_supports_pipe_alternatives() {
204        assert!(matches_matcher(Some("Edit|Write"), Some("Edit")));
205        assert!(matches_matcher(Some("Edit|Write"), Some("Write")));
206        assert!(!matches_matcher(Some("Edit|Write"), Some("Bash")));
207        assert_eq!(validate_matcher_pattern("Edit|Write"), Ok(()));
208    }
209
210    #[test]
211    fn literal_matcher_uses_exact_matching() {
212        assert!(matches_matcher(Some("Bash"), Some("Bash")));
213        assert!(!matches_matcher(Some("Bash"), Some("BashOutput")));
214        assert!(matches_matcher(
215            Some("mcp__memory__create_entities"),
216            Some("mcp__memory__create_entities")
217        ));
218        assert!(!matches_matcher(
219            Some("mcp__memory"),
220            Some("mcp__memory__create_entities")
221        ));
222        assert_eq!(validate_matcher_pattern("mcp__memory"), Ok(()));
223    }
224
225    #[test]
226    fn matcher_uses_regex_when_it_contains_regex_characters() {
227        assert!(matches_matcher(Some("^Bash"), Some("BashOutput")));
228        assert_eq!(validate_matcher_pattern("^Bash"), Ok(()));
229    }
230
231    #[test]
232    fn mcp_matchers_support_regex_wildcards() {
233        assert!(matches_matcher(
234            Some("mcp__memory__.*"),
235            Some("mcp__memory__create_entities")
236        ));
237        assert!(matches_matcher(
238            Some("mcp__.*__write.*"),
239            Some("mcp__filesystem__write_file")
240        ));
241        assert!(!matches_matcher(
242            Some("mcp__.*__write.*"),
243            Some("mcp__filesystem__read_file")
244        ));
245        assert_eq!(validate_matcher_pattern("mcp__memory__.*"), Ok(()));
246    }
247
248    #[test]
249    fn matcher_supports_anchored_regexes() {
250        assert!(matches_matcher(Some("^Bash$"), Some("Bash")));
251        assert!(!matches_matcher(Some("^Bash$"), Some("BashOutput")));
252        assert_eq!(validate_matcher_pattern("^Bash$"), Ok(()));
253    }
254
255    #[test]
256    fn invalid_regex_is_rejected() {
257        assert!(validate_matcher_pattern("[").is_err());
258        assert!(!matches_matcher(Some("["), Some("Bash")));
259    }
260
261    #[test]
262    fn unsupported_events_ignore_matchers() {
263        assert_eq!(
264            matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("^hello")),
265            None
266        );
267        assert_eq!(
268            matcher_pattern_for_event(HookEventName::Stop, Some("^done$")),
269            None
270        );
271    }
272
273    #[test]
274    fn supported_events_keep_matchers() {
275        assert_eq!(
276            matcher_pattern_for_event(HookEventName::PreToolUse, Some("Bash")),
277            Some("Bash")
278        );
279        assert_eq!(
280            matcher_pattern_for_event(HookEventName::PostToolUse, Some("Edit|Write")),
281            Some("Edit|Write")
282        );
283        assert_eq!(
284            matcher_pattern_for_event(HookEventName::SessionStart, Some("startup|resume")),
285            Some("startup|resume")
286        );
287        assert_eq!(
288            matcher_pattern_for_event(HookEventName::SessionEnd, Some("clear|other")),
289            Some("clear|other")
290        );
291        assert_eq!(
292            matcher_pattern_for_event(HookEventName::PreCompact, Some("^auto$")),
293            Some("^auto$")
294        );
295        assert_eq!(
296            matcher_pattern_for_event(HookEventName::PostCompact, Some("manual|auto")),
297            Some("manual|auto")
298        );
299    }
300}