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#[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 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(None, Some("Bash")));
185 assert!(matches_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}