1use std::path::PathBuf;
2
3use codex_protocol::ThreadId;
4use codex_protocol::protocol::HookCompletedEvent;
5use codex_protocol::protocol::HookEventName;
6use codex_protocol::protocol::HookOutputEntry;
7use codex_protocol::protocol::HookOutputEntryKind;
8use codex_protocol::protocol::HookRunStatus;
9use codex_protocol::protocol::HookRunSummary;
10use codex_utils_absolute_path::AbsolutePathBuf;
11
12use super::common;
13use crate::engine::CommandShell;
14use crate::engine::ConfiguredHandler;
15use crate::engine::command_runner::CommandRunResult;
16use crate::engine::dispatcher;
17use crate::engine::output_parser;
18use crate::schema::PostCompactCommandInput;
19use crate::schema::PreCompactCommandInput;
20use crate::schema::SubagentCommandInputFields;
21
22#[derive(Debug, Clone)]
23pub struct PreCompactRequest {
24 pub session_id: ThreadId,
25 pub turn_id: String,
26 pub subagent: Option<common::SubagentHookContext>,
27 pub cwd: AbsolutePathBuf,
28 pub transcript_path: Option<PathBuf>,
29 pub model: String,
30 pub trigger: String,
31}
32
33#[derive(Debug, Clone)]
34pub struct PostCompactRequest {
35 pub session_id: ThreadId,
36 pub turn_id: String,
37 pub subagent: Option<common::SubagentHookContext>,
38 pub cwd: AbsolutePathBuf,
39 pub transcript_path: Option<PathBuf>,
40 pub model: String,
41 pub trigger: String,
42}
43
44#[derive(Debug)]
45pub struct StatelessHookOutcome {
46 pub hook_events: Vec<HookCompletedEvent>,
47 pub should_stop: bool,
48 pub stop_reason: Option<String>,
49}
50
51#[derive(Debug)]
52pub struct PreCompactOutcome {
53 pub hook_events: Vec<HookCompletedEvent>,
54 pub should_stop: bool,
55 pub stop_reason: Option<String>,
56}
57
58pub(crate) fn preview_pre(
59 handlers: &[ConfiguredHandler],
60 request: &PreCompactRequest,
61) -> Vec<HookRunSummary> {
62 dispatcher::select_handlers(
63 handlers,
64 HookEventName::PreCompact,
65 Some(request.trigger.as_str()),
66 )
67 .into_iter()
68 .map(|handler| dispatcher::running_summary(&handler))
69 .collect()
70}
71
72pub(crate) async fn run_pre(
73 handlers: &[ConfiguredHandler],
74 shell: &CommandShell,
75 request: PreCompactRequest,
76) -> PreCompactOutcome {
77 let matched = dispatcher::select_handlers(
78 handlers,
79 HookEventName::PreCompact,
80 Some(request.trigger.as_str()),
81 );
82 if matched.is_empty() {
83 return PreCompactOutcome {
84 hook_events: Vec::new(),
85 should_stop: false,
86 stop_reason: None,
87 };
88 }
89
90 let input_json = match pre_command_input_json(&request) {
91 Ok(input_json) => input_json,
92 Err(error) => {
93 return PreCompactOutcome {
94 hook_events: common::serialization_failure_hook_events(
95 matched,
96 Some(request.turn_id),
97 format!("failed to serialize pre compact hook input: {error}"),
98 ),
99 should_stop: false,
100 stop_reason: None,
101 };
102 }
103 };
104
105 let results = dispatcher::execute_handlers(
106 shell,
107 matched,
108 input_json,
109 request.cwd.as_path(),
110 Some(request.turn_id),
111 parse_pre_completed,
112 )
113 .await;
114 let should_stop = results.iter().any(|result| result.data.should_stop);
115 let stop_reason = results
116 .iter()
117 .find_map(|result| result.data.stop_reason.clone());
118 PreCompactOutcome {
119 hook_events: results.into_iter().map(|result| result.completed).collect(),
120 should_stop,
121 stop_reason,
122 }
123}
124
125fn pre_command_input_json(request: &PreCompactRequest) -> Result<String, serde_json::Error> {
126 let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
127 serde_json::to_string(&PreCompactCommandInput {
128 session_id: request.session_id.to_string(),
129 turn_id: request.turn_id.clone(),
130 agent_id: subagent.agent_id,
131 agent_type: subagent.agent_type,
132 transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
133 cwd: request.cwd.display().to_string(),
134 hook_event_name: "PreCompact".to_string(),
135 model: request.model.clone(),
136 trigger: request.trigger.clone(),
137 })
138}
139
140pub(crate) fn preview_post(
141 handlers: &[ConfiguredHandler],
142 request: &PostCompactRequest,
143) -> Vec<HookRunSummary> {
144 dispatcher::select_handlers(
145 handlers,
146 HookEventName::PostCompact,
147 Some(request.trigger.as_str()),
148 )
149 .into_iter()
150 .map(|handler| dispatcher::running_summary(&handler))
151 .collect()
152}
153
154pub(crate) async fn run_post(
155 handlers: &[ConfiguredHandler],
156 shell: &CommandShell,
157 request: PostCompactRequest,
158) -> StatelessHookOutcome {
159 let matched = dispatcher::select_handlers(
160 handlers,
161 HookEventName::PostCompact,
162 Some(request.trigger.as_str()),
163 );
164 if matched.is_empty() {
165 return StatelessHookOutcome {
166 hook_events: Vec::new(),
167 should_stop: false,
168 stop_reason: None,
169 };
170 }
171
172 let input_json = match post_command_input_json(&request) {
173 Ok(input_json) => input_json,
174 Err(error) => {
175 return StatelessHookOutcome {
176 hook_events: common::serialization_failure_hook_events(
177 matched,
178 Some(request.turn_id),
179 format!("failed to serialize post compact hook input: {error}"),
180 ),
181 should_stop: false,
182 stop_reason: None,
183 };
184 }
185 };
186
187 let results = dispatcher::execute_handlers(
188 shell,
189 matched,
190 input_json,
191 request.cwd.as_path(),
192 Some(request.turn_id),
193 parse_post_completed,
194 )
195 .await;
196 let should_stop = results.iter().any(|result| result.data.should_stop);
197 let stop_reason = results
198 .iter()
199 .find_map(|result| result.data.stop_reason.clone());
200 StatelessHookOutcome {
201 hook_events: results.into_iter().map(|result| result.completed).collect(),
202 should_stop,
203 stop_reason,
204 }
205}
206
207fn post_command_input_json(request: &PostCompactRequest) -> Result<String, serde_json::Error> {
208 let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
209 serde_json::to_string(&PostCompactCommandInput {
210 session_id: request.session_id.to_string(),
211 turn_id: request.turn_id.clone(),
212 agent_id: subagent.agent_id,
213 agent_type: subagent.agent_type,
214 transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
215 cwd: request.cwd.display().to_string(),
216 hook_event_name: "PostCompact".to_string(),
217 model: request.model.clone(),
218 trigger: request.trigger.clone(),
219 })
220}
221
222#[derive(Default)]
223struct CompactHandlerData {
224 should_stop: bool,
225 stop_reason: Option<String>,
226}
227
228fn parse_pre_completed(
229 handler: &ConfiguredHandler,
230 run_result: CommandRunResult,
231 turn_id: Option<String>,
232) -> dispatcher::ParsedHandler<CompactHandlerData> {
233 let mut entries = Vec::new();
234 let mut status = HookRunStatus::Completed;
235 let mut should_stop = false;
236 let mut stop_reason = None;
237
238 match run_result.error.as_deref() {
239 Some(error) => {
240 status = HookRunStatus::Failed;
241 entries.push(HookOutputEntry {
242 kind: HookOutputEntryKind::Error,
243 text: error.to_string(),
244 });
245 }
246 None => match run_result.exit_code {
247 Some(0) => {
248 let trimmed_stdout = run_result.stdout.trim();
249 if trimmed_stdout.is_empty() {
250 } else if let Some(parsed) = output_parser::parse_pre_compact(&run_result.stdout) {
251 if let Some(system_message) = parsed.universal.system_message {
252 entries.push(HookOutputEntry {
253 kind: HookOutputEntryKind::Warning,
254 text: system_message,
255 });
256 }
257 let _ = parsed.universal.suppress_output;
258 if !parsed.universal.continue_processing {
259 status = HookRunStatus::Stopped;
260 should_stop = true;
261 stop_reason = parsed.universal.stop_reason.clone();
262 entries.push(HookOutputEntry {
263 kind: HookOutputEntryKind::Stop,
264 text: parsed
265 .universal
266 .stop_reason
267 .unwrap_or_else(|| "PreCompact hook stopped execution".to_string()),
268 });
269 } else if let Some(invalid_reason) = parsed.invalid_reason {
270 status = HookRunStatus::Failed;
271 entries.push(HookOutputEntry {
272 kind: HookOutputEntryKind::Error,
273 text: invalid_reason,
274 });
275 }
276 } else if output_parser::looks_like_json(&run_result.stdout) {
277 status = HookRunStatus::Failed;
278 entries.push(HookOutputEntry {
279 kind: HookOutputEntryKind::Error,
280 text: "hook returned invalid PreCompact hook JSON output".to_string(),
281 });
282 }
283 }
284 Some(code) => {
285 status = HookRunStatus::Failed;
286 entries.push(HookOutputEntry {
287 kind: HookOutputEntryKind::Error,
288 text: common::trimmed_non_empty(&run_result.stderr)
289 .unwrap_or_else(|| format!("hook exited with code {code}")),
290 });
291 }
292 None => {
293 status = HookRunStatus::Failed;
294 entries.push(HookOutputEntry {
295 kind: HookOutputEntryKind::Error,
296 text: "hook process terminated without an exit code".to_string(),
297 });
298 }
299 },
300 }
301
302 dispatcher::ParsedHandler {
303 completed: HookCompletedEvent {
304 turn_id,
305 run: dispatcher::completed_summary(handler, &run_result, status, entries),
306 },
307 data: CompactHandlerData {
308 should_stop,
309 stop_reason,
310 },
311 completion_order: 0,
312 }
313}
314
315fn parse_post_completed(
316 handler: &ConfiguredHandler,
317 run_result: CommandRunResult,
318 turn_id: Option<String>,
319) -> dispatcher::ParsedHandler<CompactHandlerData> {
320 parse_completed(
321 handler,
322 run_result,
323 turn_id,
324 "PostCompact",
325 output_parser::parse_post_compact,
326 )
327}
328
329fn parse_completed(
330 handler: &ConfiguredHandler,
331 run_result: CommandRunResult,
332 turn_id: Option<String>,
333 event_label: &'static str,
334 parse_output: fn(&str) -> Option<output_parser::StatelessHookOutput>,
335) -> dispatcher::ParsedHandler<CompactHandlerData> {
336 let mut entries = Vec::new();
337 let mut status = HookRunStatus::Completed;
338 let mut should_stop = false;
339 let mut stop_reason = None;
340
341 match run_result.error.as_deref() {
342 Some(error) => {
343 status = HookRunStatus::Failed;
344 entries.push(HookOutputEntry {
345 kind: HookOutputEntryKind::Error,
346 text: error.to_string(),
347 });
348 }
349 None => match run_result.exit_code {
350 Some(0) => {
351 let trimmed_stdout = run_result.stdout.trim();
352 if trimmed_stdout.is_empty() {
353 } else if let Some(parsed) = parse_output(&run_result.stdout) {
354 if let Some(system_message) = parsed.universal.system_message {
355 entries.push(HookOutputEntry {
356 kind: HookOutputEntryKind::Warning,
357 text: system_message,
358 });
359 }
360 let _ = parsed.universal.suppress_output;
361 if !parsed.universal.continue_processing {
362 status = HookRunStatus::Stopped;
363 should_stop = true;
364 stop_reason = parsed.universal.stop_reason.clone();
365 entries.push(HookOutputEntry {
366 kind: HookOutputEntryKind::Stop,
367 text: parsed
368 .universal
369 .stop_reason
370 .unwrap_or_else(|| format!("{event_label} hook stopped execution")),
371 });
372 } else if let Some(invalid_reason) = parsed.invalid_reason {
373 status = HookRunStatus::Failed;
374 entries.push(HookOutputEntry {
375 kind: HookOutputEntryKind::Error,
376 text: invalid_reason,
377 });
378 }
379 } else if output_parser::looks_like_json(&run_result.stdout) {
380 status = HookRunStatus::Failed;
381 entries.push(HookOutputEntry {
382 kind: HookOutputEntryKind::Error,
383 text: format!("hook returned invalid {event_label} hook JSON output"),
384 });
385 }
386 }
387 Some(code) => {
388 status = HookRunStatus::Failed;
389 entries.push(HookOutputEntry {
390 kind: HookOutputEntryKind::Error,
391 text: common::trimmed_non_empty(&run_result.stderr)
392 .unwrap_or_else(|| format!("hook exited with code {code}")),
393 });
394 }
395 None => {
396 status = HookRunStatus::Failed;
397 entries.push(HookOutputEntry {
398 kind: HookOutputEntryKind::Error,
399 text: "hook process terminated without an exit code".to_string(),
400 });
401 }
402 },
403 }
404
405 dispatcher::ParsedHandler {
406 completed: HookCompletedEvent {
407 turn_id,
408 run: dispatcher::completed_summary(handler, &run_result, status, entries),
409 },
410 data: CompactHandlerData {
411 should_stop,
412 stop_reason,
413 },
414 completion_order: 0,
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use codex_protocol::ThreadId;
421 use codex_protocol::protocol::HookEventName;
422 use codex_protocol::protocol::HookOutputEntry;
423 use codex_protocol::protocol::HookOutputEntryKind;
424 use codex_protocol::protocol::HookRunStatus;
425 use codex_utils_absolute_path::test_support::PathBufExt;
426 use codex_utils_absolute_path::test_support::test_path_buf;
427 use pretty_assertions::assert_eq;
428 use serde_json::json;
429
430 use super::parse_post_completed;
431 use super::parse_pre_completed;
432 use super::post_command_input_json;
433 use super::pre_command_input_json;
434 use crate::engine::ConfiguredHandler;
435 use crate::engine::command_runner::CommandRunResult;
436
437 #[test]
438 fn pre_compact_input_includes_lifecycle_metadata() {
439 let input_json = pre_command_input_json(&pre_request()).expect("serialize command input");
440 let input: serde_json::Value =
441 serde_json::from_str(&input_json).expect("parse command input");
442
443 assert_eq!(
444 input,
445 json!({
446 "session_id": pre_request().session_id.to_string(),
447 "turn_id": "turn-1",
448 "transcript_path": null,
449 "cwd": test_path_buf("/tmp").display().to_string(),
450 "hook_event_name": "PreCompact",
451 "model": "gpt-test",
452 "trigger": "manual",
453 })
454 );
455 }
456
457 #[test]
458 fn post_compact_input_includes_lifecycle_metadata() {
459 let input_json = post_command_input_json(&post_request()).expect("serialize command input");
460 let input: serde_json::Value =
461 serde_json::from_str(&input_json).expect("parse command input");
462
463 assert_eq!(
464 input,
465 json!({
466 "session_id": post_request().session_id.to_string(),
467 "turn_id": "turn-1",
468 "transcript_path": null,
469 "cwd": test_path_buf("/tmp").display().to_string(),
470 "hook_event_name": "PostCompact",
471 "model": "gpt-test",
472 "trigger": "manual",
473 })
474 );
475 }
476
477 #[test]
478 fn block_decision_is_not_supported_for_pre_compact() {
479 let parsed = parse_pre_completed(
480 &handler(HookEventName::PreCompact),
481 run_result(
482 Some(0),
483 r#"{"decision":"block","reason":"policy blocked compaction"}"#,
484 "",
485 ),
486 Some("turn-1".to_string()),
487 );
488
489 assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
490 assert_eq!(
491 parsed.completed.run.entries,
492 vec![HookOutputEntry {
493 kind: HookOutputEntryKind::Error,
494 text: "hook returned invalid PreCompact hook JSON output".to_string(),
495 }]
496 );
497 }
498
499 #[test]
500 fn continue_false_stops_before_compaction() {
501 let parsed = parse_pre_completed(
502 &handler(HookEventName::PreCompact),
503 run_result(Some(0), r#"{"continue":false,"stopReason":"nope"}"#, ""),
504 Some("turn-1".to_string()),
505 );
506
507 assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
508 assert_eq!(parsed.data.should_stop, true);
509 assert_eq!(parsed.data.stop_reason, Some("nope".to_string()));
510 assert_eq!(
511 parsed.completed.run.entries,
512 vec![HookOutputEntry {
513 kind: HookOutputEntryKind::Stop,
514 text: "nope".to_string(),
515 }]
516 );
517 }
518
519 #[test]
520 fn post_compact_continue_false_stops_after_compaction() {
521 let parsed = parse_post_completed(
522 &handler(HookEventName::PostCompact),
523 run_result(
524 Some(0),
525 r#"{"continue":false,"stopReason":"pause after compact"}"#,
526 "",
527 ),
528 Some("turn-1".to_string()),
529 );
530
531 assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
532 assert_eq!(parsed.data.should_stop, true);
533 assert_eq!(
534 parsed.data.stop_reason,
535 Some("pause after compact".to_string())
536 );
537 assert_eq!(
538 parsed.completed.run.entries,
539 vec![HookOutputEntry {
540 kind: HookOutputEntryKind::Stop,
541 text: "pause after compact".to_string(),
542 }]
543 );
544 }
545
546 #[test]
547 fn pre_compact_ignores_plain_stdout() {
548 let parsed = parse_pre_completed(
549 &handler(HookEventName::PreCompact),
550 run_result(Some(0), "checking compact policy\n", ""),
551 Some("turn-1".to_string()),
552 );
553
554 assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
555 assert_eq!(parsed.completed.run.entries, Vec::new());
556 }
557
558 #[test]
559 fn post_compact_ignores_plain_stdout() {
560 let parsed = parse_post_completed(
561 &handler(HookEventName::PostCompact),
562 run_result(Some(0), "logged compact summary\n", ""),
563 Some("turn-1".to_string()),
564 );
565
566 assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
567 assert_eq!(parsed.completed.run.entries, Vec::new());
568 }
569
570 fn pre_request() -> super::PreCompactRequest {
571 super::PreCompactRequest {
572 session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000001")
573 .expect("valid thread id"),
574 turn_id: "turn-1".to_string(),
575 subagent: None,
576 cwd: test_path_buf("/tmp").abs(),
577 transcript_path: None,
578 model: "gpt-test".to_string(),
579 trigger: "manual".to_string(),
580 }
581 }
582
583 fn post_request() -> super::PostCompactRequest {
584 super::PostCompactRequest {
585 session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000002")
586 .expect("valid thread id"),
587 turn_id: "turn-1".to_string(),
588 subagent: None,
589 cwd: test_path_buf("/tmp").abs(),
590 transcript_path: None,
591 model: "gpt-test".to_string(),
592 trigger: "manual".to_string(),
593 }
594 }
595
596 fn handler(event_name: HookEventName) -> ConfiguredHandler {
597 ConfiguredHandler {
598 event_name,
599 matcher: None,
600 command: "python3 compact_hook.py".to_string(),
601 timeout_sec: 5,
602 status_message: Some("running compact hook".to_string()),
603 additional_context_limit: Default::default(),
604 source_path: test_path_buf("/tmp/hooks.json").abs(),
605 source: codex_protocol::protocol::HookSource::User,
606 display_order: 0,
607 env: std::collections::HashMap::new(),
608 }
609 }
610
611 fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
612 CommandRunResult {
613 started_at: 1_700_000_000,
614 completed_at: 1_700_000_001,
615 duration_ms: 12,
616 exit_code,
617 stdout: stdout.to_string(),
618 stderr: stderr.to_string(),
619 error: None,
620 }
621 }
622}