Skip to main content

codex_helper_core/
notify.rs

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    // Drop very old pending items to avoid unbounded growth.
267    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            // Without proxy access we cannot compute duration_ms reliably, so skip (D strategy).
322            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        &notify_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    // If user forces toast for this invocation, we still rely on config for policy.
369    // We reuse cfg.notify for queue/flush; system notifications can be enabled only for this run.
370    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        // Apply per-thread cooldown and prepare toast batch.
426        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(&notify_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                // Best-effort stale lock cleanup (2 minutes).
620                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        // No-op fallback: print a short line for non-supported platforms.
651        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("&amp;"),
732                '<' => escaped.push_str("&lt;"),
733                '>' => escaped.push_str("&gt;"),
734                '"' => escaped.push_str("&quot;"),
735                '\'' => escaped.push_str("&apos;"),
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 &amp; b");
749            assert_eq!(escape_for_xml("5 > 3"), "5 &gt; 3");
750            assert_eq!(escape_for_xml("<tag>"), "&lt;tag&gt;");
751            assert_eq!(escape_for_xml("\"quoted\""), "&quot;quoted&quot;");
752            assert_eq!(escape_for_xml("single 'quote'"), "single &apos;quote&apos;");
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}