Skip to main content

fno_agents/
mail_inject.rs

1//! `mail-inject`: the one-shot LIVE-DELIVERY verb `fno mail send` calls to inject
2//! an a2a turn into a LIVE adopted `claude --bg` session over the daemon
3//! `control.sock`. Python's `_deliver_live` runs it as a binary subprocess and
4//! falls back to the durable bus queue ONLY when this reports not-delivered
5//! (live-inject-first, durable fallback -- node x-1f23, epic x-07c1).
6//!
7//! Binary-direct (Python subprocess), NOT a routable `fno agents` verb -- it is
8//! dispatched via `matches!` in `client.rs`, like `version`/`--emit-schema`, so it
9//! stays out of the verb-parity lists (`RUST_CLIENT_VERBS` / `CLIENT_VERB_USAGE`).
10//!
11//! Reuses the G1 substrate for roster resolution ([`crate::claude_roster`]) ->
12//! `control.sock` + `control.key` and the attach handshake
13//! ([`crate::claude_attach`]). Post-attach the socket is a RAW keystroke pipe, so
14//! the turn is bracketed-PASTED as raw bytes and submitted with a wire-level CR --
15//! NOT an `op:'reply'` JSON frame, which would land (auth key included) as literal
16//! text in the recipient input box, unsent (node x-178e). The `<fno_mail>` envelope is
17//! rendered Python-side (the single renderer, shared by the codex/gemini + relay
18//! paths) and injected verbatim here, so this verb is a dumb transport.
19//!
20//! Delivery confirm = the injected turn's `<fno_mail>` open tag appears in the
21//! recipient transcript AFTER the inject (content match, [`confirm_content_after`]).
22//! A submitted turn is recorded verbatim; an unsent input box records nothing. This
23//! replaces the earlier transcript-GROWTH proxy, which false-confirmed on a BUSY
24//! recipient whose transcript grows continuously from an unrelated turn (node
25//! x-178e).
26//!
27//! ponytail: content-confirm still has one bounded edge -- a BUSY recipient may
28//! queue the injected turn past the poll budget; we report not-confirmed and Python
29//! writes the durable fallback, yet the queued paste still lands later, a bounded
30//! DOUBLE delivery. Hard exactly-once needs recipient-side msg_id dedup on the
31//! envelope (follow-up); the bounded duplicate is the accepted live-first tradeoff.
32
33use std::io::{self, BufRead, Read, Seek};
34use std::path::Path;
35use std::time::Duration;
36
37use crate::claude_attach::{perform_attach, AttachRequest, UnixControlTransport};
38use crate::claude_drive::{contains_detach_sentinel, find_transcript, transcript_len, DriveError};
39use crate::claude_roster::{read_control_key, ClaudeRoster};
40
41/// Default transcript-growth poll budget: 40 * 250ms = 10s. A live blocked
42/// session echoes the injected turn well within this; a miss demotes to durable.
43/// `pub` so the in-process ask-lane fallback (`claude_ask`) reuses the SAME
44/// budget the shelled `mail-inject` verb uses, keeping the two paths byte-parity.
45pub const DEFAULT_ATTEMPTS: u32 = 40;
46pub const DEFAULT_INTERVAL_MS: u64 = 250;
47
48/// Settle delay between the envelope inject and the wire-level CR submit. The
49/// paste needs to register in the recipient input box before the Enter
50/// keystroke lands; the proven recipe (2026-07-08, CC 2.1.205) used ~0.8s.
51const CR_SETTLE_MS: u64 = 800;
52
53/// Interval multiple at which the confirm loop re-sends the wire-level CR. The
54/// initial CR (from `inject_with_submit`) can be swallowed mid-paste by a BUSY
55/// recipient streaming a turn, leaving the envelope sitting unsent; re-Entering
56/// every ~2s (8 * 250ms) lands it once the recipient drains. Idempotent: a bare
57/// Enter on an empty/already-submitted input box is a no-op in CC.
58const CR_RESUBMIT_EVERY: u32 = 8;
59
60/// Live-inject target harness. `claude` is the default `control.sock` path;
61/// `codex` routes to the app-server daemon ([`crate::codex_inject`], US8).
62#[derive(Debug, PartialEq, Clone, Copy)]
63pub enum MailInjectProvider {
64    Claude,
65    Codex,
66}
67
68/// Axis-rename tombstone (x-bab1): the harness axis was `--provider`, now
69/// `--harness/-H`. A model vendor routes only at spawn. Mirrors the Python
70/// `_flag_aliases.PROVIDER_AXIS_TOMBSTONE` (kept in lockstep).
71const PROVIDER_AXIS_TOMBSTONE: &str = concat!(
72    "--provider was split at the axis rename: the CLI binary is --harness/-H; ",
73    "a model vendor is only routable at spawn ",
74    "(`fno agents spawn --provider <vendor> --model <m>`).",
75);
76
77/// Parsed `mail-inject` flags. The turn TEXT is read from STDIN (sidesteps the
78/// argv size limit for envelopes up to the 1 MiB send cap); everything else is a
79/// flag.
80#[derive(Debug, PartialEq)]
81pub struct MailInjectArgs {
82    /// Recipient: full session UUID OR its 8-hex short id (roster accepts either)
83    /// for claude; the codex threadId (full UUID) for codex.
84    pub session: String,
85    pub provider: MailInjectProvider,
86    pub attempts: u32,
87    pub interval_ms: u64,
88}
89
90/// Parse `mail-inject` argv (everything after the verb). Pure + total so the flag
91/// grammar is unit-tested without a daemon.
92pub fn parse_args(rest: &[String]) -> Result<MailInjectArgs, (i32, String)> {
93    let mut session: Option<String> = None;
94    let mut provider = MailInjectProvider::Claude;
95    let mut attempts = DEFAULT_ATTEMPTS;
96    let mut interval_ms = DEFAULT_INTERVAL_MS;
97    let mut it = rest.iter();
98    while let Some(a) = it.next() {
99        match a.as_str() {
100            "--session" => {
101                session = Some(
102                    it.next()
103                        .ok_or((2, "mail-inject: --session needs a value".to_string()))?
104                        .to_string(),
105                );
106            }
107            "--harness" | "-H" => {
108                provider = match it.next().map(String::as_str) {
109                    Some("claude") => MailInjectProvider::Claude,
110                    Some("codex") => MailInjectProvider::Codex,
111                    _ => {
112                        return Err((
113                            2,
114                            "mail-inject: --harness must be claude or codex".to_string(),
115                        ))
116                    }
117                };
118            }
119            "--provider" => return Err((2, PROVIDER_AXIS_TOMBSTONE.to_string())),
120            "--attempts" => {
121                attempts = it.next().and_then(|v| v.parse().ok()).ok_or((
122                    2,
123                    "mail-inject: --attempts needs a positive integer".to_string(),
124                ))?;
125            }
126            "--interval-ms" => {
127                interval_ms = it.next().and_then(|v| v.parse().ok()).ok_or((
128                    2,
129                    "mail-inject: --interval-ms needs a positive integer".to_string(),
130                ))?;
131            }
132            other => {
133                return Err((2, format!("mail-inject: unknown flag: {other}")));
134            }
135        }
136    }
137    let session = session.ok_or((2, "mail-inject: --session is required".to_string()))?;
138    Ok(MailInjectArgs {
139        session,
140        provider,
141        attempts,
142        interval_ms,
143    })
144}
145
146/// The single JSON outcome line Python parses: `{"delivered": bool, "reason": str}`.
147/// Pure so the contract is unit-tested.
148pub fn outcome_json(delivered: bool, reason: &str) -> String {
149    serde_json::json!({ "delivered": delivered, "reason": reason }).to_string()
150}
151
152/// Exit code for an outcome: 0 when delivered, 1 otherwise. Python branches on the
153/// JSON `delivered` field; the exit code is the same signal for shell callers.
154pub fn outcome_exit(delivered: bool) -> i32 {
155    i32::from(!delivered)
156}
157
158/// Print the outcome JSON to stdout and return its exit code.
159fn emit(delivered: bool, reason: &str) -> i32 {
160    println!("{}", outcome_json(delivered, reason));
161    outcome_exit(delivered)
162}
163
164/// Bracketed-paste guards (xterm DEC mode 2004): the recipient TUI treats
165/// everything between them as ONE paste event. Required because a `<fno_mail>`
166/// envelope is multi-line (`open_tag\nbody\n</fno_mail>`), and a raw multi-line
167/// write without them submits line-by-line -- the recipient records the open tag
168/// alone (enough to satisfy the content confirm) while the body arrives as
169/// separate input, dropping the message. Contract: `docs/architecture/fno-agents-deliver-gate.md`.
170const PASTE_BEGIN: &str = "\x1b[200~";
171const PASTE_END: &str = "\x1b[201~";
172
173/// Paste the envelope as RAW BYTES on the ATTACHED transport -- wrapped in
174/// bracketed-paste guards so a multi-line body lands as ONE paste -- settle, then
175/// send a separate raw `\r` byte as the Enter. Post-attach the `control.sock` is a
176/// raw keystroke pipe (node x-178e): an `op:'reply'` JSON write here lands its
177/// frames -- auth key included -- as literal text in the recipient input box,
178/// unsent. So we type the turn exactly as a human would: paste, then a wire-level
179/// CR. The CR is a distinct write, NOT `\r` appended to the paste -- an embedded
180/// `\r` is paste content, only a separate keystroke is the Enter. Refuses text
181/// carrying a detach sentinel before any write. Extracted so the raw sequence is
182/// unit-testable against a `Fake` transport (settle=ZERO).
183fn inject_with_submit<T: crate::claude_attach::ControlTransport>(
184    transport: &mut T,
185    text: &str,
186    settle: Duration,
187) -> Result<(), DriveError> {
188    if contains_detach_sentinel(text) {
189        return Err(DriveError::UnsafeText);
190    }
191    transport
192        .send_line(&format!("{PASTE_BEGIN}{text}{PASTE_END}"))
193        .map_err(|e| DriveError::Io(e.to_string()))?;
194    std::thread::sleep(settle);
195    transport
196        .send_line("\r")
197        .map_err(|e| DriveError::Io(e.to_string()))
198}
199
200/// Poll `confirmed` (a content check on the recipient transcript), re-sending the
201/// raw wire-level CR every `CR_RESUBMIT_EVERY` intervals so a CR the busy recipient
202/// swallowed mid-paste gets re-Entered once it drains. `Ok(())` on a confirmed
203/// landing, `Err("not-confirmed")` on budget exhaustion. Extracted from the
204/// transport + transcript so the retry cadence is unit-testable against a `Fake`
205/// (interval=ZERO). Re-send errors are ignored: it is best-effort, and a dead
206/// transport fails the confirm anyway.
207fn confirm_with_cr_retry<T: crate::claude_attach::ControlTransport>(
208    transport: &mut T,
209    attempts: u32,
210    interval: Duration,
211    mut confirmed: impl FnMut() -> bool,
212) -> Result<(), &'static str> {
213    for i in 0..attempts.max(1) {
214        if confirmed() {
215            return Ok(());
216        }
217        std::thread::sleep(interval);
218        if (i + 1) % CR_RESUBMIT_EVERY == 0 {
219            let _ = transport.send_line("\r");
220        }
221    }
222    Err("not-confirmed")
223}
224
225/// The escaped form of `marker` as it appears inside a transcript JSONL line: the
226/// injected turn is stored as a JSON string, so quotes/backslashes in the marker
227/// are escaped there too. Strip the surrounding quotes `serde_json` adds, leaving a
228/// raw substring to search for.
229fn escaped_marker(marker: &str) -> String {
230    let s = serde_json::to_string(marker).unwrap_or_default();
231    s.strip_prefix('"')
232        .and_then(|s| s.strip_suffix('"'))
233        .unwrap_or("")
234        .to_string()
235}
236
237/// Confirm the injected turn LANDED by CONTENT, not transcript growth: scan lines
238/// appended after `since_byte` for the injected turn's `marker` (its `<fno_mail>`
239/// open tag). A submitted turn is recorded verbatim; an unsent input box records
240/// nothing, and a busy recipient's unrelated growth never carries our marker -- so
241/// this rejects the growth-only false positive (node x-178e). `since_byte` is a
242/// prior full-file length, hence a clean line boundary.
243fn confirm_content_after(path: &Path, marker: &str, since_byte: u64) -> io::Result<bool> {
244    let escaped = escaped_marker(marker);
245    if escaped.is_empty() {
246        return Ok(false);
247    }
248    let mut file = std::fs::File::open(path)?;
249    file.seek(io::SeekFrom::Start(since_byte))?;
250    for line in io::BufReader::new(file).lines() {
251        if line?.contains(&escaped) {
252            return Ok(true);
253        }
254    }
255    Ok(false)
256}
257
258/// Deliver `text` to `session` over the daemon `control.sock`: resolve the
259/// recipient on the roster, attach, paste the envelope + wire-level CR submit, and
260/// confirm by CONTENT that the injected turn landed in the recipient transcript.
261/// `Ok(())` == delivered (the `<fno_mail>` marker appeared after the inject);
262/// `Err(reason)` is a clean not-delivered signal whose value IS the `mail-inject`
263/// JSON `reason` token.
264///
265/// The SINGLE control.sock wire implementation (Locked Decision 1, node
266/// x-2681): both the `mail-inject` verb (`fno mail send`) and the Rust ask-lane
267/// fallback (`claude_ask::ask_followup`) deliver through here, so the wire
268/// contract lives in one place and can never drift. `text` is injected verbatim
269/// -- a dumb transport; callers wrap it in the `<fno_mail>` /
270/// `<cross-session-message>` envelope first.
271pub fn deliver_via_control_sock(
272    session: &str,
273    text: &str,
274    attempts: u32,
275    interval_ms: u64,
276) -> Result<(), &'static str> {
277    // Resolve the recipient on the claude daemon roster. Any miss == not live
278    // reachable.
279    let roster = ClaudeRoster::load_default().map_err(|_| "not-live")?;
280    let worker = roster.find(session).ok_or("not-live")?;
281    let sock = worker.resolve_control_sock().ok_or("not-live")?;
282    let short = worker.short_id().to_string();
283    let auth = read_control_key();
284
285    // Locate the recipient transcript. No transcript yet == we cannot confirm
286    // landing.
287    let transcript = find_transcript(&worker.session_id).ok_or("no-transcript")?;
288
289    let mut transport = UnixControlTransport::connect(&sock).map_err(|_| "io-error")?;
290    if perform_attach(
291        &mut transport,
292        &AttachRequest::for_frame_stream(short.clone(), auth.clone()),
293    )
294    .is_err()
295    {
296        return Err("attach-failed");
297    }
298    // Baseline the transcript byte-length AFTER attach, immediately before inject,
299    // so attach side-effects cannot be mistaken for our turn landing (codex peer
300    // P2); the content confirm scans only lines appended past this offset.
301    let baseline = transcript_len(&transcript);
302    // The injected turn's opening line -- its `<fno_mail>` open tag -- is the
303    // content marker the confirm greps for; it is recorded verbatim once the turn
304    // submits.
305    let marker = text.lines().next().unwrap_or(text);
306    inject_with_submit(&mut transport, text, Duration::from_millis(CR_SETTLE_MS)).map_err(|e| {
307        match e {
308            DriveError::UnsafeText => "unsafe-text",
309            _ => "io-error",
310        }
311    })?;
312
313    confirm_with_cr_retry(
314        &mut transport,
315        attempts,
316        Duration::from_millis(interval_ms),
317        || confirm_content_after(&transcript, marker, baseline).unwrap_or(false),
318    )
319}
320
321/// Run `mail-inject`. Reads the turn TEXT from STDIN and delivers it to the
322/// target harness (`--harness claude` over `control.sock`, default; `codex`
323/// over the app-server daemon, US8); emits the single JSON outcome line Python
324/// parses. Every `not-delivered` reason is a clean signal for Python to write
325/// the durable fallback. The claude delivery stays sync ([`deliver_via_control_sock`]);
326/// codex awaits [`crate::codex_inject::deliver_via_codex_daemon`] on the caller's
327/// runtime (no nested runtime).
328pub async fn run_mail_inject(rest: &[String]) -> i32 {
329    let args = match parse_args(rest) {
330        Ok(a) => a,
331        Err((code, msg)) => {
332            eprintln!("{msg}");
333            return code;
334        }
335    };
336
337    let mut text = String::new();
338    if let Err(e) = std::io::stdin().read_to_string(&mut text) {
339        eprintln!("mail-inject: reading stdin: {e}");
340        return emit(false, "io-error");
341    }
342
343    let result = match args.provider {
344        MailInjectProvider::Claude => {
345            deliver_via_control_sock(&args.session, &text, args.attempts, args.interval_ms)
346        }
347        MailInjectProvider::Codex => {
348            crate::codex_inject::deliver_via_codex_daemon(&args.session, &text).await
349        }
350    };
351    match result {
352        Ok(()) => emit(true, "delivered"),
353        Err(reason) => emit(false, reason),
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::claude_attach::ControlTransport;
361    use crate::claude_drive::DETACH_SENTINELS;
362    use std::fs::{File, OpenOptions};
363    use std::io::{self, Write};
364    use std::path::PathBuf;
365
366    /// Records every raw byte-write, so a test can assert the paste + CR sequence.
367    struct Fake {
368        sent: Vec<String>,
369    }
370    impl ControlTransport for Fake {
371        fn send_line(&mut self, line: &str) -> io::Result<()> {
372            self.sent.push(line.to_string());
373            Ok(())
374        }
375        fn recv_line(&mut self) -> io::Result<Option<String>> {
376            Ok(None)
377        }
378    }
379
380    fn argv(parts: &[&str]) -> Vec<String> {
381        parts.iter().map(|s| s.to_string()).collect()
382    }
383
384    fn tmp_transcript(tag: &str) -> PathBuf {
385        let dir = std::env::temp_dir().join(format!("mailinj-{}-{}", tag, std::process::id()));
386        std::fs::create_dir_all(&dir).unwrap();
387        dir.join("t.jsonl")
388    }
389
390    #[test]
391    fn inject_with_submit_bracketed_pastes_then_separate_cr() {
392        let mut t = Fake { sent: Vec::new() };
393        let envelope = "<fno_mail from=\"a1b2c3d4\" node=\"x-178e\">\nhi MARKER\n</fno_mail>";
394        inject_with_submit(&mut t, envelope, Duration::ZERO).unwrap();
395        // The multi-line envelope is ONE bracketed paste, then a SEPARATE wire-level
396        // CR -- not `\r` appended to the paste. Bracketed-paste guards keep the
397        // embedded newlines from submitting the body line-by-line.
398        assert_eq!(
399            t.sent,
400            vec![
401                format!("{PASTE_BEGIN}{envelope}{PASTE_END}"),
402                "\r".to_string()
403            ]
404        );
405        // The paste carries the RAW envelope verbatim, NEVER an op:'reply' JSON frame
406        // (the x-178e bug): no `op` key, and the control auth key is never typed in.
407        assert!(t.sent[0].contains(envelope), "envelope pasted verbatim");
408        assert!(
409            !t.sent[0].contains("\"op\""),
410            "envelope must be raw bytes, not a JSON op"
411        );
412        assert!(
413            !t.sent[0].contains("auth"),
414            "raw paste must never carry the control auth key"
415        );
416    }
417
418    #[test]
419    fn inject_with_submit_refuses_unsafe_envelope_and_writes_nothing() {
420        let mut t = Fake { sent: Vec::new() };
421        let err = inject_with_submit(&mut t, DETACH_SENTINELS[0], Duration::ZERO);
422        assert!(matches!(err, Err(DriveError::UnsafeText)));
423        assert!(t.sent.is_empty(), "unsafe envelope must not paste or CR");
424    }
425
426    #[test]
427    fn busy_recipient_gets_raw_paste_then_retried_crs() {
428        let mut t = Fake { sent: Vec::new() };
429        inject_with_submit(&mut t, "hi MARKER", Duration::ZERO).unwrap();
430        // Confirm never fires -> the loop exhausts its budget, re-Entering a raw CR
431        // once per CR_RESUBMIT_EVERY window.
432        let attempts = 2 * CR_RESUBMIT_EVERY; // two resubmit windows
433        let r = confirm_with_cr_retry(&mut t, attempts, Duration::ZERO, || false);
434        assert_eq!(r, Err("not-confirmed"));
435        // paste + initial CR (inject_with_submit) + one CR per resubmit window.
436        assert_eq!(t.sent.len() as u32, 2 + attempts / CR_RESUBMIT_EVERY);
437        // Every write after the paste is a bare raw CR -- no JSON, no auth.
438        for line in &t.sent[1..] {
439            assert_eq!(line, "\r");
440        }
441    }
442
443    #[test]
444    fn confirm_stops_on_landing_without_extra_cr() {
445        let mut t = Fake { sent: Vec::new() };
446        let mut calls = 0;
447        let r = confirm_with_cr_retry(&mut t, 40, Duration::ZERO, || {
448            calls += 1;
449            calls >= 2
450        });
451        assert_eq!(r, Ok(()));
452        assert!(
453            t.sent.is_empty(),
454            "landing before a resubmit window sends no CR"
455        );
456    }
457
458    #[test]
459    fn content_confirm_rejects_growth_and_accepts_the_landed_envelope() {
460        let path = tmp_transcript("content");
461        let mut f = File::create(&path).unwrap();
462        writeln!(
463            f,
464            r#"{{"type":"user","message":{{"role":"user","content":"older"}}}}"#
465        )
466        .unwrap();
467        let baseline = transcript_len(&path);
468        let marker = "<fno_mail from=\"a1b2c3d4\" node=\"x-178e\">";
469
470        // A BUSY recipient GROWS the transcript with unrelated output -> growth
471        // alone must NOT confirm.
472        let mut f = OpenOptions::new().append(true).open(&path).unwrap();
473        writeln!(
474            f,
475            r#"{{"type":"assistant","message":{{"role":"assistant","content":"streaming something else"}}}}"#
476        )
477        .unwrap();
478        assert!(
479            !confirm_content_after(&path, marker, baseline).unwrap(),
480            "growth without the marker must not confirm"
481        );
482
483        // The injected turn lands verbatim (JSON-escaped) -> confirm by content.
484        writeln!(
485            f,
486            r#"{{"type":"user","message":{{"role":"user","content":"{}\nhi\n</fno_mail>"}}}}"#,
487            escaped_marker(marker)
488        )
489        .unwrap();
490        assert!(
491            confirm_content_after(&path, marker, baseline).unwrap(),
492            "the landed envelope confirms delivery"
493        );
494        std::fs::remove_dir_all(path.parent().unwrap()).ok();
495    }
496
497    #[test]
498    fn parse_args_requires_session() {
499        assert_eq!(parse_args(&[]).unwrap_err().0, 2);
500        assert_eq!(
501            parse_args(&argv(&["--attempts", "5"])).unwrap_err().0,
502            2,
503            "no --session is an error even with other flags"
504        );
505    }
506
507    #[test]
508    fn parse_args_defaults_and_overrides() {
509        let a = parse_args(&argv(&["--session", "a1b2c3d4"])).unwrap();
510        assert_eq!(a.session, "a1b2c3d4");
511        assert_eq!(a.provider, MailInjectProvider::Claude);
512        assert_eq!(a.attempts, DEFAULT_ATTEMPTS);
513        assert_eq!(a.interval_ms, DEFAULT_INTERVAL_MS);
514
515        let b = parse_args(&argv(&[
516            "--session",
517            "a1b2c3d4-1111-2222-3333-444455556666",
518            "--attempts",
519            "3",
520            "--interval-ms",
521            "10",
522        ]))
523        .unwrap();
524        assert_eq!(b.session, "a1b2c3d4-1111-2222-3333-444455556666");
525        assert_eq!(b.attempts, 3);
526        assert_eq!(b.interval_ms, 10);
527    }
528
529    #[test]
530    fn parse_args_harness_defaults_claude_and_accepts_codex() {
531        let d = parse_args(&argv(&["--session", "x"])).unwrap();
532        assert_eq!(d.provider, MailInjectProvider::Claude);
533        let c = parse_args(&argv(&["--session", "x", "--harness", "codex"])).unwrap();
534        assert_eq!(c.provider, MailInjectProvider::Codex);
535        // -H is the harness short flag.
536        let h = parse_args(&argv(&["--session", "x", "-H", "codex"])).unwrap();
537        assert_eq!(h.provider, MailInjectProvider::Codex);
538        // Unknown harness is a usage error.
539        assert_eq!(
540            parse_args(&argv(&["--session", "x", "--harness", "gemini"]))
541                .unwrap_err()
542                .0,
543            2
544        );
545    }
546
547    #[test]
548    fn parse_args_provider_is_the_axis_rename_tombstone() {
549        // --provider was the harness axis; it now exits 2 with the axis map
550        // (x-bab1), regardless of value. Reverting the tombstone arm makes this
551        // test fail (AC6).
552        let err = parse_args(&argv(&["--session", "x", "--provider", "codex"])).unwrap_err();
553        assert_eq!(err.0, 2);
554        assert!(
555            err.1.contains("--harness/-H"),
556            "tombstone points at --harness: {err:?}"
557        );
558    }
559
560    #[test]
561    fn parse_args_rejects_unknown_flag_and_missing_value() {
562        assert_eq!(parse_args(&argv(&["--nope"])).unwrap_err().0, 2);
563        assert_eq!(parse_args(&argv(&["--session"])).unwrap_err().0, 2);
564        assert_eq!(
565            parse_args(&argv(&["--session", "x", "--attempts", "notnum"]))
566                .unwrap_err()
567                .0,
568            2
569        );
570    }
571
572    #[test]
573    fn outcome_json_is_the_python_contract() {
574        let v: serde_json::Value = serde_json::from_str(&outcome_json(true, "delivered")).unwrap();
575        assert_eq!(v["delivered"], true);
576        assert_eq!(v["reason"], "delivered");
577        let w: serde_json::Value = serde_json::from_str(&outcome_json(false, "not-live")).unwrap();
578        assert_eq!(w["delivered"], false);
579        assert_eq!(w["reason"], "not-live");
580    }
581
582    #[test]
583    fn outcome_exit_maps_delivered_to_zero() {
584        assert_eq!(outcome_exit(true), 0);
585        assert_eq!(outcome_exit(false), 1);
586    }
587}