1use std::collections::HashMap;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use serde::{Deserialize, Serialize};
8use tokio::time::sleep;
9
10use crate::config::{NotifyConfig, NotifyPolicyConfig, load_config, proxy_home_dir};
11use crate::file_replace::write_bytes_file_async;
12
13#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "kebab-case")]
15enum CodexNotificationType {
16 AgentTurnComplete,
17 #[serde(other)]
18 Unknown,
19}
20
21#[derive(Debug, Clone, Deserialize, PartialEq)]
22#[serde(rename_all = "kebab-case")]
23struct CodexNotificationInput {
24 r#type: CodexNotificationType,
25 #[serde(default)]
26 thread_id: Option<String>,
27 #[serde(default)]
28 turn_id: Option<String>,
29 #[serde(default)]
30 cwd: Option<String>,
31 #[serde(default)]
32 input_messages: Option<Vec<String>>,
33 #[serde(default)]
34 last_assistant_message: Option<String>,
35}
36
37#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
38struct FinishedRequestLite {
39 #[serde(default)]
40 session_id: Option<String>,
41 #[serde(default)]
42 cwd: Option<String>,
43 service: String,
44 method: String,
45 path: String,
46 status_code: u16,
47 duration_ms: u64,
48 ended_at_ms: u64,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52struct QueuedEvent {
53 thread_id: String,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 turn_id: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 cwd: Option<String>,
58 duration_ms: u64,
59 ended_at_ms: u64,
60 queued_at_ms: u64,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 last_assistant_preview: Option<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66struct NotifyState {
67 version: u32,
68 #[serde(default)]
69 pending: Vec<QueuedEvent>,
70 #[serde(default)]
71 last_toast_ms: Option<u64>,
72 #[serde(default)]
73 per_thread_last_toast_ms: HashMap<String, u64>,
74 #[serde(default)]
75 suppressed_since_last_toast: u64,
76}
77
78fn now_ms() -> u64 {
79 SystemTime::now()
80 .duration_since(UNIX_EPOCH)
81 .map(|d| d.as_millis() as u64)
82 .unwrap_or(0)
83}
84
85fn read_payload(notification_json: Option<String>) -> std::io::Result<Option<String>> {
86 if let Some(s) = notification_json {
87 return Ok(Some(s));
88 }
89
90 if atty::is(atty::Stream::Stdin) {
91 return Ok(None);
92 }
93
94 let mut buf = String::new();
95 std::io::stdin().read_to_string(&mut buf)?;
96 let buf = buf.trim().to_string();
97 if buf.is_empty() {
98 Ok(None)
99 } else {
100 Ok(Some(buf))
101 }
102}
103
104fn shorten(input: &str, max_chars: usize) -> String {
105 let s = input.trim();
106 if s.chars().count() <= max_chars {
107 return s.to_string();
108 }
109 let mut out: String = s.chars().take(max_chars).collect();
110 out.push_str("...");
111 out
112}
113
114fn notify_state_path() -> PathBuf {
115 proxy_home_dir().join("notify_state.json")
116}
117
118fn notify_lock_path() -> PathBuf {
119 proxy_home_dir().join("notify_state.lock")
120}
121
122fn codex_proxy_base_url_from_codex_config_text(text: &str) -> Option<String> {
123 let value: toml::Value = toml::from_str(text).ok()?;
124 let table = value.as_table()?;
125 let providers = table.get("model_providers")?.as_table()?;
126 let proxy = providers.get("codex_proxy")?.as_table()?;
127 proxy
128 .get("base_url")
129 .and_then(|v| v.as_str())
130 .map(|s| s.to_string())
131}
132
133async fn get_proxy_base_url() -> Option<String> {
134 if let Ok(v) = std::env::var("CODEX_HELPER_NOTIFY_PROXY_BASE_URL")
135 && !v.trim().is_empty()
136 {
137 return Some(v);
138 }
139
140 let codex_cfg_path = crate::config::codex_config_path();
141 let text = tokio::fs::read_to_string(codex_cfg_path).await.ok()?;
142 codex_proxy_base_url_from_codex_config_text(&text)
143}
144
145fn pick_best_recent_request(
146 thread_id: &str,
147 cwd: Option<&str>,
148 now_ms: u64,
149 policy: &NotifyPolicyConfig,
150 recent: &[FinishedRequestLite],
151) -> Option<FinishedRequestLite> {
152 let min_ended_at = now_ms.saturating_sub(policy.recent_search_window_ms);
153
154 let mut candidates = recent
155 .iter()
156 .filter(|r| r.service == "codex")
157 .filter(|r| r.ended_at_ms >= min_ended_at)
158 .filter(|r| r.session_id.as_deref() == Some(thread_id))
159 .cloned()
160 .collect::<Vec<_>>();
161
162 if candidates.is_empty()
163 && let Some(cwd) = cwd
164 {
165 candidates = recent
166 .iter()
167 .filter(|r| r.service == "codex")
168 .filter(|r| r.ended_at_ms >= min_ended_at)
169 .filter(|r| r.cwd.as_deref() == Some(cwd))
170 .cloned()
171 .collect::<Vec<_>>();
172 }
173
174 candidates
175 .into_iter()
176 .max_by_key(|r| (request_path_score(&r.path), r.ended_at_ms))
177}
178
179fn request_path_score(path: &str) -> u8 {
180 let p = path.to_ascii_lowercase();
181 if p.contains("responses") {
182 2
183 } else if p.contains("chat") {
184 1
185 } else {
186 0
187 }
188}
189
190async fn fetch_recent_finished(
191 proxy_base_url: &str,
192 timeout_ms: u64,
193) -> anyhow::Result<Vec<FinishedRequestLite>> {
194 #[derive(serde::Deserialize)]
195 struct AdminDiscovery {
196 admin_base_url: String,
197 }
198
199 let client = reqwest::Client::builder()
200 .timeout(Duration::from_millis(timeout_ms))
201 .build()?;
202
203 let mut base_candidates = Vec::new();
204 if let Some(admin_base_url) = crate::proxy::admin_base_url_from_proxy_base_url(proxy_base_url) {
205 base_candidates.push(admin_base_url);
206 }
207 let proxy_base_url = proxy_base_url.trim_end_matches('/').to_string();
208 if !base_candidates.iter().any(|base| base == &proxy_base_url) {
209 base_candidates.push(proxy_base_url.clone());
210 }
211
212 if let Ok(resp) = client
213 .get(format!(
214 "{}/.well-known/codex-helper-admin",
215 proxy_base_url.trim_end_matches('/')
216 ))
217 .send()
218 .await
219 && resp.status().is_success()
220 && let Ok(discovery) = resp.json::<AdminDiscovery>().await
221 {
222 let admin_base_url = discovery.admin_base_url.trim_end_matches('/').to_string();
223 if !admin_base_url.is_empty() && !base_candidates.iter().any(|base| base == &admin_base_url)
224 {
225 base_candidates.insert(0, admin_base_url);
226 }
227 }
228
229 let mut last_err: Option<anyhow::Error> = None;
230 for base_url in base_candidates {
231 let url = format!("{base_url}/__codex_helper/api/v1/status/recent?limit=200");
232 match client.get(url).send().await {
233 Ok(resp) => {
234 let status = resp.status();
235 if !status.is_success() {
236 last_err = Some(anyhow::anyhow!(
237 "proxy api/v1/status/recent returned {}",
238 status.as_u16()
239 ));
240 continue;
241 }
242 let items = resp.json::<Vec<FinishedRequestLite>>().await?;
243 return Ok(items);
244 }
245 Err(err) => {
246 last_err = Some(err.into());
247 }
248 }
249 }
250
251 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("proxy api/v1/status/recent unavailable")))
252}
253
254async fn queue_event_and_spawn_flush(
255 cfg: &NotifyConfig,
256 event: QueuedEvent,
257 force_toast: bool,
258) -> anyhow::Result<()> {
259 let _lock = acquire_notify_lock().await?;
260
261 let mut state = load_state().await.unwrap_or_default();
262 if state.version == 0 {
263 state.version = 1;
264 }
265
266 let cutoff = now_ms().saturating_sub(30 * 60_000);
268 state.pending.retain(|e| e.queued_at_ms >= cutoff);
269 state.pending.push(event);
270 save_state(&state).await?;
271
272 if cfg.enabled && (cfg.system.enabled || (cfg.exec.enabled && !cfg.exec.command.is_empty())) {
273 spawn_flush_process(force_toast)?;
274 }
275 Ok(())
276}
277
278pub async fn handle_codex_notify(
279 notification_json: Option<String>,
280 no_toast: bool,
281 force_toast: bool,
282) -> anyhow::Result<()> {
283 let Some(payload) = read_payload(notification_json)? else {
284 return Ok(());
285 };
286
287 let cfg = load_config().await?;
288 let notify_cfg = cfg.notify;
289 let system_enabled =
290 force_toast || (notify_cfg.enabled && notify_cfg.system.enabled && !no_toast);
291 let exec_enabled =
292 notify_cfg.enabled && notify_cfg.exec.enabled && !notify_cfg.exec.command.is_empty();
293
294 if !system_enabled && !exec_enabled {
295 return Ok(());
296 }
297
298 let payload: CodexNotificationInput = match serde_json::from_str(&payload) {
299 Ok(v) => v,
300 Err(err) => {
301 eprintln!("codex-helper notify: failed to parse notification JSON: {err}");
302 return Ok(());
303 }
304 };
305
306 if payload.r#type != CodexNotificationType::AgentTurnComplete {
307 return Ok(());
308 }
309
310 let Some(thread_id) = payload
311 .thread_id
312 .as_deref()
313 .filter(|s| !s.trim().is_empty())
314 else {
315 return Ok(());
316 };
317
318 let proxy_base_url = match get_proxy_base_url().await {
319 Some(v) => v,
320 None => {
321 return Ok(());
323 }
324 };
325
326 let recent = match fetch_recent_finished(
327 &proxy_base_url,
328 notify_cfg.policy.recent_endpoint_timeout_ms,
329 )
330 .await
331 {
332 Ok(v) => v,
333 Err(_) => return Ok(()),
334 };
335
336 let now = now_ms();
337 let best = pick_best_recent_request(
338 thread_id,
339 payload.cwd.as_deref(),
340 now,
341 ¬ify_cfg.policy,
342 &recent,
343 );
344 let Some(best) = best else {
345 return Ok(());
346 };
347
348 if best.duration_ms < notify_cfg.policy.min_duration_ms {
349 return Ok(());
350 }
351
352 let preview = payload
353 .last_assistant_message
354 .as_deref()
355 .map(|s| shorten(s, 160))
356 .filter(|s| !s.trim().is_empty());
357
358 let event = QueuedEvent {
359 thread_id: thread_id.to_string(),
360 turn_id: payload.turn_id.clone(),
361 cwd: payload.cwd.clone(),
362 duration_ms: best.duration_ms,
363 ended_at_ms: best.ended_at_ms,
364 queued_at_ms: now,
365 last_assistant_preview: preview,
366 };
367
368 let mut cfg_for_queue = notify_cfg.clone();
371 if force_toast {
372 cfg_for_queue.enabled = true;
373 cfg_for_queue.system.enabled = true;
374 }
375 if no_toast {
376 cfg_for_queue.system.enabled = false;
377 }
378
379 queue_event_and_spawn_flush(&cfg_for_queue, event, force_toast).await
380}
381
382pub async fn handle_codex_flush() -> anyhow::Result<()> {
383 let cfg = load_config().await?;
384 let notify_cfg = cfg.notify;
385 let force_toast = matches!(
386 std::env::var("CODEX_HELPER_NOTIFY_FORCE_TOAST"),
387 Ok(v) if v == "1" || v.eq_ignore_ascii_case("true")
388 );
389
390 if !notify_cfg.enabled && !force_toast {
391 return Ok(());
392 }
393
394 for _ in 0..20 {
395 let _lock = acquire_notify_lock().await?;
396 let mut state = load_state().await.unwrap_or_default();
397 if state.pending.is_empty() {
398 return Ok(());
399 }
400
401 let now = now_ms();
402 let first_pending = state
403 .pending
404 .iter()
405 .map(|e| e.queued_at_ms)
406 .min()
407 .unwrap_or(now);
408 let due_ms = first_pending.saturating_add(notify_cfg.policy.merge_window_ms);
409
410 if now < due_ms {
411 drop(state);
412 sleep(Duration::from_millis((due_ms - now).min(60_000))).await;
413 continue;
414 }
415
416 if let Some(last) = state.last_toast_ms
417 && now.saturating_sub(last) < notify_cfg.policy.global_cooldown_ms
418 {
419 let wait = notify_cfg.policy.global_cooldown_ms - now.saturating_sub(last);
420 drop(state);
421 sleep(Duration::from_millis(wait.min(60_000))).await;
422 continue;
423 }
424
425 state.pending.sort_by_key(|e| e.ended_at_ms);
427 let mut send: Vec<QueuedEvent> = Vec::new();
428 let mut suppressed = 0u64;
429 for e in state.pending.iter() {
430 let last = state.per_thread_last_toast_ms.get(&e.thread_id).copied();
431 if let Some(last) = last
432 && now.saturating_sub(last) < notify_cfg.policy.per_thread_cooldown_ms
433 {
434 suppressed = suppressed.saturating_add(1);
435 continue;
436 }
437 send.push(e.clone());
438 }
439
440 if send.is_empty() {
441 state.pending.clear();
442 state.suppressed_since_last_toast =
443 state.suppressed_since_last_toast.saturating_add(suppressed);
444 save_state(&state).await?;
445 return Ok(());
446 }
447
448 let system_enabled = notify_cfg.system.enabled || force_toast;
449 let exec_enabled = notify_cfg.exec.enabled && !notify_cfg.exec.command.is_empty();
450 if !system_enabled && !exec_enabled {
451 state.pending.clear();
452 save_state(&state).await?;
453 return Ok(());
454 }
455
456 let title = render_title(send.len(), suppressed, state.suppressed_since_last_toast);
457 let body = render_body(&send);
458 let aggregated = serde_json::json!({
459 "type": "codex-helper-merged-agent-turn-complete",
460 "count": send.len(),
461 "suppressed_in_batch": suppressed,
462 "suppressed_since_last_toast": state.suppressed_since_last_toast,
463 "generated_at_ms": now,
464 "events": send,
465 })
466 .to_string();
467
468 if system_enabled && let Err(err) = send_system_notification(&title, &body) {
469 eprintln!("codex-helper notify: failed to show system notification: {err}");
470 }
471 if exec_enabled && let Err(err) = run_exec_callback(¬ify_cfg.exec.command, &aggregated) {
472 eprintln!("codex-helper notify: exec callback failed: {err}");
473 }
474
475 state.last_toast_ms = Some(now);
476 for e in send.iter() {
477 state
478 .per_thread_last_toast_ms
479 .insert(e.thread_id.clone(), now);
480 }
481 state.suppressed_since_last_toast = 0;
482 state.pending.clear();
483 save_state(&state).await?;
484 return Ok(());
485 }
486
487 Ok(())
488}
489
490fn render_title(count: usize, suppressed_in_batch: u64, suppressed_since_last: u64) -> String {
491 let mut title = if count == 1 {
492 "Codex: turn complete".to_string()
493 } else {
494 format!("Codex: {count} turns complete")
495 };
496 let total_suppressed = suppressed_in_batch.saturating_add(suppressed_since_last);
497 if total_suppressed > 0 {
498 title.push_str(&format!(" (+{total_suppressed} suppressed)"));
499 }
500 title
501}
502
503fn render_body(events: &[QueuedEvent]) -> String {
504 let mut lines: Vec<String> = Vec::new();
505 for e in events.iter().rev().take(3) {
506 let dur_s = (e.duration_ms as f64 / 1000.0).max(0.0);
507 let cwd = e
508 .cwd
509 .as_deref()
510 .and_then(|p| Path::new(p).file_name().and_then(|s| s.to_str()))
511 .unwrap_or("-");
512 if let Some(preview) = e.last_assistant_preview.as_deref() {
513 lines.push(format!("{cwd} ({dur_s:.1}s): {}", shorten(preview, 90)));
514 } else {
515 lines.push(format!("{cwd} ({dur_s:.1}s)"));
516 }
517 }
518 if events.len() > 3 {
519 lines.push(format!("+{} more", events.len() - 3));
520 }
521 lines.join("\n")
522}
523
524fn run_exec_callback(command: &[String], input_json: &str) -> anyhow::Result<()> {
525 if command.is_empty() {
526 return Ok(());
527 }
528 let mut cmd = Command::new(&command[0]);
529 if command.len() > 1 {
530 cmd.args(&command[1..]);
531 }
532 cmd.stdin(Stdio::piped())
533 .stdout(Stdio::null())
534 .stderr(Stdio::null());
535 let mut child = cmd.spawn()?;
536 if let Some(mut stdin) = child.stdin.take() {
537 use std::io::Write;
538 stdin.write_all(input_json.as_bytes())?;
539 }
540 let _ = child.wait();
541 Ok(())
542}
543
544fn spawn_flush_process(force_toast: bool) -> anyhow::Result<()> {
545 let exe = std::env::current_exe()?;
546 let mut cmd = Command::new(exe);
547 cmd.arg("notify").arg("flush-codex");
548 if force_toast {
549 cmd.env("CODEX_HELPER_NOTIFY_FORCE_TOAST", "1");
550 }
551 cmd.stdin(Stdio::null())
552 .stdout(Stdio::null())
553 .stderr(Stdio::null());
554
555 #[cfg(windows)]
556 {
557 use std::os::windows::process::CommandExt;
558 const DETACHED_PROCESS: u32 = 0x00000008;
559 const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
560 cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
561 }
562
563 let _ = cmd.spawn()?;
564 Ok(())
565}
566
567async fn load_state() -> anyhow::Result<NotifyState> {
568 let path = notify_state_path();
569 if !path.exists() {
570 return Ok(NotifyState {
571 version: 1,
572 ..Default::default()
573 });
574 }
575 let bytes = tokio::fs::read(path).await?;
576 let mut state = serde_json::from_slice::<NotifyState>(&bytes)?;
577 if state.version == 0 {
578 state.version = 1;
579 }
580 Ok(state)
581}
582
583async fn save_state(state: &NotifyState) -> anyhow::Result<()> {
584 let dir = proxy_home_dir();
585 tokio::fs::create_dir_all(&dir).await?;
586 let path = notify_state_path();
587 let data = serde_json::to_vec_pretty(state)?;
588 write_bytes_file_async(&path, &data).await?;
589 Ok(())
590}
591
592struct NotifyLockGuard {
593 path: PathBuf,
594}
595
596impl Drop for NotifyLockGuard {
597 fn drop(&mut self) {
598 let _ = std::fs::remove_file(&self.path);
599 }
600}
601
602async fn acquire_notify_lock() -> anyhow::Result<NotifyLockGuard> {
603 let path = notify_lock_path();
604 let dir = proxy_home_dir();
605 tokio::fs::create_dir_all(&dir).await?;
606
607 for _ in 0..200 {
608 match std::fs::OpenOptions::new()
609 .write(true)
610 .create_new(true)
611 .open(&path)
612 {
613 Ok(mut f) => {
614 use std::io::Write;
615 let _ = writeln!(f, "{}", now_ms());
616 return Ok(NotifyLockGuard { path });
617 }
618 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
619 if let Ok(meta) = std::fs::metadata(&path)
621 && let Ok(modified) = meta.modified()
622 && let Ok(age) = SystemTime::now().duration_since(modified)
623 && age > Duration::from_secs(120)
624 {
625 let _ = std::fs::remove_file(&path);
626 continue;
627 }
628 sleep(Duration::from_millis(10)).await;
629 }
630 Err(err) => return Err(err.into()),
631 }
632 }
633
634 anyhow::bail!("failed to acquire notify lock: {:?}", path);
635}
636
637fn send_system_notification(title: &str, body: &str) -> anyhow::Result<()> {
638 #[cfg(windows)]
639 {
640 windows_toast::notify(title, body)?;
641 Ok(())
642 }
643 #[cfg(target_os = "macos")]
644 {
645 macos_notification::notify(title, body)?;
646 Ok(())
647 }
648 #[cfg(not(any(windows, target_os = "macos")))]
649 {
650 println!("{title}: {body}");
652 Ok(())
653 }
654}
655
656#[cfg(windows)]
657mod windows_toast {
658 use std::io;
659 use std::process::{Command, Stdio};
660
661 use base64::Engine as _;
662 use base64::engine::general_purpose::STANDARD as BASE64;
663
664 const APP_ID: &str = "codex-helper";
665 const POWERSHELL_EXE: &str = "powershell.exe";
666
667 pub fn notify(title: &str, body: &str) -> io::Result<()> {
668 let encoded_title = encode_argument(title);
669 let encoded_body = encode_argument(body);
670 let encoded_command = build_encoded_command(&encoded_title, &encoded_body);
671
672 let mut command = Command::new(POWERSHELL_EXE);
673 command
674 .arg("-NoProfile")
675 .arg("-NoLogo")
676 .arg("-EncodedCommand")
677 .arg(encoded_command)
678 .stdin(Stdio::null())
679 .stdout(Stdio::null())
680 .stderr(Stdio::null());
681
682 let status = command.status()?;
683 if status.success() {
684 Ok(())
685 } else {
686 Err(io::Error::other(format!(
687 "{POWERSHELL_EXE} exited with status {status}"
688 )))
689 }
690 }
691
692 fn build_encoded_command(encoded_title: &str, encoded_body: &str) -> String {
693 let script = build_ps_script(encoded_title, encoded_body);
694 encode_script_for_powershell(&script)
695 }
696
697 fn build_ps_script(encoded_title: &str, encoded_body: &str) -> String {
698 format!(
699 r#"
700$encoding = [System.Text.Encoding]::UTF8
701$titleText = $encoding.GetString([System.Convert]::FromBase64String("{encoded_title}"))
702$bodyText = $encoding.GetString([System.Convert]::FromBase64String("{encoded_body}"))
703[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
704$doc = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
705$textNodes = $doc.GetElementsByTagName("text")
706$textNodes.Item(0).AppendChild($doc.CreateTextNode($titleText)) | Out-Null
707$textNodes.Item(1).AppendChild($doc.CreateTextNode($bodyText)) | Out-Null
708$toast = [Windows.UI.Notifications.ToastNotification]::new($doc)
709[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{app_id}').Show($toast)
710"#,
711 app_id = APP_ID
712 )
713 }
714
715 fn encode_script_for_powershell(script: &str) -> String {
716 let mut wide: Vec<u8> = Vec::with_capacity((script.len() + 1) * 2);
717 for unit in script.encode_utf16() {
718 wide.extend_from_slice(&unit.to_le_bytes());
719 }
720 BASE64.encode(wide)
721 }
722
723 fn encode_argument(value: &str) -> String {
724 BASE64.encode(escape_for_xml(value))
725 }
726
727 fn escape_for_xml(input: &str) -> String {
728 let mut escaped = String::with_capacity(input.len());
729 for ch in input.chars() {
730 match ch {
731 '&' => escaped.push_str("&"),
732 '<' => escaped.push_str("<"),
733 '>' => escaped.push_str(">"),
734 '"' => escaped.push_str("""),
735 '\'' => escaped.push_str("'"),
736 _ => escaped.push(ch),
737 }
738 }
739 escaped
740 }
741
742 #[cfg(test)]
743 mod tests {
744 use super::escape_for_xml;
745
746 #[test]
747 fn escapes_xml_entities() {
748 assert_eq!(escape_for_xml("a & b"), "a & b");
749 assert_eq!(escape_for_xml("5 > 3"), "5 > 3");
750 assert_eq!(escape_for_xml("<tag>"), "<tag>");
751 assert_eq!(escape_for_xml("\"quoted\""), ""quoted"");
752 assert_eq!(escape_for_xml("single 'quote'"), "single 'quote'");
753 }
754 }
755}
756
757#[cfg(target_os = "macos")]
758mod macos_notification {
759 use std::io;
760 use std::process::{Command, Stdio};
761
762 pub fn notify(title: &str, body: &str) -> io::Result<()> {
763 let script = format!(
764 "display notification {} with title {}",
765 apple_quote(body),
766 apple_quote(title)
767 );
768 let status = Command::new("osascript")
769 .arg("-e")
770 .arg(script)
771 .stdin(Stdio::null())
772 .stdout(Stdio::null())
773 .stderr(Stdio::null())
774 .status()?;
775 if status.success() {
776 Ok(())
777 } else {
778 Err(io::Error::other(format!(
779 "osascript exited with status {status}"
780 )))
781 }
782 }
783
784 fn apple_quote(s: &str) -> String {
785 format!("\"{}\"", s.replace('\\', "\\\\").replace('\"', "\\\""))
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 #[test]
794 fn parses_agent_turn_complete_payload_with_thread_id() {
795 let payload = r#"{
796 "type": "agent-turn-complete",
797 "thread-id": "th1",
798 "turn-id": "t1",
799 "cwd": "/tmp/x",
800 "input-messages": ["run tests"],
801 "last-assistant-message": "ok"
802 }"#;
803 let parsed: CodexNotificationInput = serde_json::from_str(payload).expect("parse");
804 assert_eq!(parsed.r#type, CodexNotificationType::AgentTurnComplete);
805 assert_eq!(parsed.thread_id.as_deref(), Some("th1"));
806 assert_eq!(parsed.turn_id.as_deref(), Some("t1"));
807 }
808
809 #[test]
810 fn picks_best_recent_request_prefers_responses_path() {
811 let policy = NotifyPolicyConfig::default();
812 let now = 1_000_000u64;
813 let recent = vec![
814 FinishedRequestLite {
815 session_id: Some("th1".to_string()),
816 cwd: Some("/p".to_string()),
817 service: "codex".to_string(),
818 method: "POST".to_string(),
819 path: "/v1/chat/completions".to_string(),
820 status_code: 200,
821 duration_ms: 10_000,
822 ended_at_ms: now - 1_000,
823 },
824 FinishedRequestLite {
825 session_id: Some("th1".to_string()),
826 cwd: Some("/p".to_string()),
827 service: "codex".to_string(),
828 method: "POST".to_string(),
829 path: "/v1/responses".to_string(),
830 status_code: 200,
831 duration_ms: 20_000,
832 ended_at_ms: now - 10_000,
833 },
834 ];
835 let best =
836 pick_best_recent_request("th1", Some("/p"), now, &policy, &recent).expect("best");
837 assert_eq!(best.path, "/v1/responses");
838 }
839}