Skip to main content

codex_wrapper/command/
exec.rs

1use crate::Codex;
2use crate::command::CodexCommand;
3#[cfg(feature = "json")]
4use crate::error::Error;
5use crate::error::Result;
6use crate::exec::{self, CommandOutput};
7use crate::rollout_budget::RolloutBudgetConfig;
8use crate::types::{ApprovalPolicyConfig, Color, SandboxMode, WebSearchMode};
9#[cfg(feature = "json")]
10use crate::types::{JsonLineEvent, QueryResult};
11
12/// Push the typed config-key overrides shared by the exec-family builders.
13///
14/// `codex-cli` 0.145.0 removed `--ask-for-approval` and `--search` from the
15/// exec family; both settings moved to `-c` config keys. These are pushed
16/// before any caller-supplied [`config`](ExecCommand::config) strings because
17/// `-c` is last-wins, so a raw override still beats the typed setter.
18pub(crate) fn push_typed_config(
19    args: &mut Vec<String>,
20    approval_policy: Option<ApprovalPolicyConfig>,
21    web_search: Option<WebSearchMode>,
22) {
23    if let Some(policy) = approval_policy {
24        args.push("-c".into());
25        args.push(format!("approval_policy=\"{}\"", policy.as_config_value()));
26    }
27    if let Some(mode) = web_search {
28        args.push("-c".into());
29        args.push(format!("web_search=\"{}\"", mode.as_config_value()));
30    }
31}
32
33/// Resolve the sandbox mode, folding in the deprecated `full_auto` shim.
34///
35/// `--full-auto` is hidden on the exec family (and rejected outright by `fork`
36/// and `resume`); the CLI's own advice is `--sandbox workspace-write`. An
37/// explicit `sandbox()` call is more specific and wins.
38pub(crate) fn effective_sandbox(
39    sandbox: Option<SandboxMode>,
40    full_auto: bool,
41) -> Option<SandboxMode> {
42    sandbox.or(full_auto.then_some(SandboxMode::WorkspaceWrite))
43}
44
45/// Run Codex non-interactively (`codex exec <prompt>`).
46///
47/// This is the primary command for programmatic use. It supports the full
48/// range of exec flags: model selection, sandbox policy, images, config
49/// overrides, feature flags, JSON output, and more.
50///
51/// # Example
52///
53/// ```no_run
54/// use codex_wrapper::{Codex, CodexCommand, ExecCommand, SandboxMode};
55///
56/// # async fn example() -> codex_wrapper::Result<()> {
57/// let codex = Codex::builder().build()?;
58/// let output = ExecCommand::new("fix the failing test")
59///     .model("o3")
60///     .sandbox(SandboxMode::WorkspaceWrite)
61///     .ephemeral()
62///     .execute(&codex)
63///     .await?;
64/// println!("{}", output.stdout);
65/// # Ok(())
66/// # }
67/// ```
68#[derive(Debug, Clone)]
69pub struct ExecCommand {
70    approve_for_me: bool,
71    prompt: Option<String>,
72    prompt_via_stdin: bool,
73    approval_policy: Option<ApprovalPolicyConfig>,
74    web_search: Option<WebSearchMode>,
75    config_overrides: Vec<String>,
76    enabled_features: Vec<String>,
77    disabled_features: Vec<String>,
78    rollout_budget: Option<RolloutBudgetConfig>,
79    images: Vec<String>,
80    model: Option<String>,
81    oss: bool,
82    local_provider: Option<String>,
83    sandbox: Option<SandboxMode>,
84    strict_config: bool,
85    dangerously_bypass_hook_trust: bool,
86    ignore_user_config: bool,
87    ignore_rules: bool,
88    profile: Option<String>,
89    full_auto: bool,
90    dangerously_bypass_approvals_and_sandbox: bool,
91    cd: Option<String>,
92    skip_git_repo_check: bool,
93    add_dirs: Vec<String>,
94    ephemeral: bool,
95    output_schema: Option<String>,
96    color: Option<Color>,
97    json: bool,
98    output_last_message: Option<String>,
99    retry_policy: Option<crate::retry::RetryPolicy>,
100}
101
102impl ExecCommand {
103    /// Create a new exec command with the given prompt.
104    #[must_use]
105    pub fn new(prompt: impl Into<String>) -> Self {
106        Self {
107            approve_for_me: false,
108            prompt: Some(prompt.into()),
109            prompt_via_stdin: false,
110            approval_policy: None,
111            web_search: None,
112            config_overrides: Vec::new(),
113            enabled_features: Vec::new(),
114            disabled_features: Vec::new(),
115            rollout_budget: None,
116            images: Vec::new(),
117            model: None,
118            oss: false,
119            local_provider: None,
120            sandbox: None,
121            strict_config: false,
122            dangerously_bypass_hook_trust: false,
123            ignore_user_config: false,
124            ignore_rules: false,
125            profile: None,
126            full_auto: false,
127            dangerously_bypass_approvals_and_sandbox: false,
128            cd: None,
129            skip_git_repo_check: false,
130            add_dirs: Vec::new(),
131            ephemeral: false,
132            output_schema: None,
133            color: None,
134            json: false,
135            output_last_message: None,
136            retry_policy: None,
137        }
138    }
139
140    /// Send the prompt on stdin instead of as an argument (`codex exec -`).
141    ///
142    /// Shorthand for [`new`](Self::new) followed by
143    /// [`prompt_via_stdin`](Self::prompt_via_stdin). Use it for prompts that
144    /// are large or awkward to pass through argv.
145    ///
146    /// ```no_run
147    /// use codex_wrapper::{Codex, CodexCommand, ExecCommand};
148    ///
149    /// # async fn example() -> codex_wrapper::Result<()> {
150    /// let codex = Codex::builder().build()?;
151    /// let diff = std::fs::read_to_string("huge.patch")?;
152    /// let output = ExecCommand::from_stdin(format!("Review this patch:\n{diff}"))
153    ///     .execute(&codex)
154    ///     .await?;
155    /// # let _ = output;
156    /// # Ok(())
157    /// # }
158    /// ```
159    ///
160    /// Before 0.3 this took no argument and set the prompt to the literal
161    /// `-`, which could not work: nothing wrote to the child's stdin, so the
162    /// CLI saw an immediate EOF and an empty prompt (#81).
163    #[must_use]
164    pub fn from_stdin(prompt: impl Into<String>) -> Self {
165        Self::new(prompt).prompt_via_stdin()
166    }
167
168    /// Deliver this command's prompt on stdin rather than in argv.
169    ///
170    /// The prompt is replaced by `-` in the argument list and written to the
171    /// child's stdin instead.
172    ///
173    /// Retry does not apply to a stdin prompt, and any policy set on the
174    /// command or the client is ignored for it. A second attempt would need to
175    /// write the prompt again, into a pipe the first attempt has already
176    /// consumed, and retrying with an empty stdin would be worse than not
177    /// retrying.
178    #[must_use]
179    pub fn prompt_via_stdin(mut self) -> Self {
180        self.prompt_via_stdin = true;
181        self
182    }
183
184    /// The prompt to write to the child's stdin, if this command sends it
185    /// there. `None` when the prompt travels in argv.
186    ///
187    /// Only the streaming path needs this, and that path is `json`-gated.
188    #[cfg(feature = "json")]
189    pub(crate) fn stdin_prompt(&self) -> Option<&str> {
190        self.prompt_via_stdin
191            .then(|| self.prompt.as_deref().unwrap_or_default())
192    }
193
194    /// Override a config key (`-c key=value`).
195    ///
196    /// May be called multiple times to set several keys. Because `-c` is
197    /// last-wins, a key set here overrides the same key set by
198    /// [`approval_policy`](Self::approval_policy) or
199    /// [`search_mode`](Self::search_mode).
200    #[must_use]
201    pub fn config(mut self, key_value: impl Into<String>) -> Self {
202        self.config_overrides.push(key_value.into());
203        self
204    }
205
206    /// Set when the model asks for approval (`-c approval_policy="<value>"`).
207    ///
208    /// `codex-cli` 0.145.0 removed `--ask-for-approval` from `codex exec`; the
209    /// config key is the supported equivalent. Accepts an
210    /// [`ApprovalPolicy`](crate::ApprovalPolicy) directly, or an
211    /// [`ApprovalPolicyConfig`] for the two values the flag never took.
212    ///
213    /// ```
214    /// use codex_wrapper::{ApprovalPolicyConfig, CodexCommand, ExecCommand};
215    ///
216    /// let args = ExecCommand::new("hi")
217    ///     .approval_policy(ApprovalPolicyConfig::Never)
218    ///     .args();
219    /// assert!(args.windows(2).any(|w| w == ["-c", "approval_policy=\"never\""]));
220    /// ```
221    #[must_use]
222    pub fn approval_policy(mut self, policy: impl Into<ApprovalPolicyConfig>) -> Self {
223        self.approval_policy = Some(policy.into());
224        self
225    }
226
227    /// Enable live web search.
228    ///
229    /// Shorthand for `search_mode(WebSearchMode::Live)`, which is what the
230    /// removed `--search` flag meant.
231    #[must_use]
232    pub fn search(self) -> Self {
233        self.search_mode(WebSearchMode::Live)
234    }
235
236    /// Set the web search mode (`-c web_search="<value>"`).
237    ///
238    /// `codex-cli` 0.145.0 removed `--search` from `codex exec`; the config
239    /// key is the supported equivalent, and it is an enum rather than the
240    /// flag's boolean.
241    #[must_use]
242    pub fn search_mode(mut self, mode: WebSearchMode) -> Self {
243        self.web_search = Some(mode);
244        self
245    }
246
247    /// Enable an optional feature flag (`--enable <feature>`).
248    ///
249    /// May be called multiple times.
250    #[must_use]
251    pub fn enable(mut self, feature: impl Into<String>) -> Self {
252        self.enabled_features.push(feature.into());
253        self
254    }
255
256    /// Disable an optional feature flag (`--disable <feature>`).
257    ///
258    /// May be called multiple times.
259    #[must_use]
260    pub fn disable(mut self, feature: impl Into<String>) -> Self {
261        self.disabled_features.push(feature.into());
262        self
263    }
264
265    /// Enforce a Codex-native rollout-unit budget for this execution.
266    ///
267    /// Codex checks the budget at response boundaries, so one response can
268    /// cross the limit before the run stops. Codex 0.145-0.146 use weighted
269    /// output and non-cached input; starting with 0.147, a provider-supplied
270    /// rollout-unit value takes precedence when available. Neither is
271    /// portable total-token usage. See [`RolloutBudgetConfig`] for the exact
272    /// versioned contract.
273    ///
274    /// This typed override is emitted after raw config, and conflicting
275    /// `rollout_budget` feature toggles from both this command and its
276    /// [`crate::Codex`] client are suppressed. Codex applies feature toggles
277    /// after every `-c` value regardless of argv order, so retaining either
278    /// toggle would otherwise disable or replace this table.
279    #[must_use]
280    pub fn rollout_budget(mut self, budget: RolloutBudgetConfig) -> Self {
281        self.rollout_budget = Some(budget);
282        self
283    }
284
285    /// Attach an image to the prompt (`--image <path>`).
286    ///
287    /// May be called multiple times to attach several images.
288    #[must_use]
289    pub fn image(mut self, path: impl Into<String>) -> Self {
290        self.images.push(path.into());
291        self
292    }
293
294    /// Set the model to use (`--model <model>`).
295    ///
296    /// Panics if `model` is an empty string.
297    #[must_use]
298    pub fn model(mut self, model: impl Into<String>) -> Self {
299        let model = model.into();
300        assert!(!model.is_empty(), "model name must not be empty");
301        self.model = Some(model);
302        self
303    }
304
305    /// Use the OSS model tier (`--oss`).
306    #[must_use]
307    pub fn oss(mut self) -> Self {
308        self.oss = true;
309        self
310    }
311
312    /// Use a local model provider (`--local-provider <provider>`).
313    #[must_use]
314    pub fn local_provider(mut self, provider: impl Into<String>) -> Self {
315        self.local_provider = Some(provider.into());
316        self
317    }
318
319    /// Set the sandbox policy (`--sandbox <mode>`).
320    #[must_use]
321    pub fn sandbox(mut self, sandbox: SandboxMode) -> Self {
322        self.sandbox = Some(sandbox);
323        self
324    }
325
326    /// Error on unrecognized config keys (`--strict-config`).
327    #[must_use]
328    pub fn strict_config(mut self) -> Self {
329        self.strict_config = true;
330        self
331    }
332
333    /// Bypass the hook trust prompt (`--dangerously-bypass-hook-trust`).
334    ///
335    /// Allows configured hooks to run without confirmation. Use with caution.
336    #[must_use]
337    pub(crate) fn set_bypass_hook_trust(mut self) -> Self {
338        self.dangerously_bypass_hook_trust = true;
339        self
340    }
341
342    /// Ignore the user-level config file (`--ignore-user-config`).
343    #[must_use]
344    pub fn ignore_user_config(mut self) -> Self {
345        self.ignore_user_config = true;
346        self
347    }
348
349    /// Ignore project rules files (`--ignore-rules`).
350    #[must_use]
351    pub fn ignore_rules(mut self) -> Self {
352        self.ignore_rules = true;
353        self
354    }
355
356    /// Select a named configuration profile (`--profile <name>`).
357    #[must_use]
358    pub fn profile(mut self, profile: impl Into<String>) -> Self {
359        self.profile = Some(profile.into());
360        self
361    }
362
363    /// Run in full-auto mode, emitted as `--sandbox workspace-write`.
364    ///
365    /// `--full-auto` is deprecated upstream. `codex-cli` 0.145.0 hides it from
366    /// `codex exec --help` and warns when it is used:
367    ///
368    /// ```text
369    /// warning: `--full-auto` is deprecated; use `--sandbox workspace-write` instead.
370    /// ```
371    ///
372    /// This method emits the replacement the CLI names. An explicit
373    /// [`sandbox`](Self::sandbox) call is more specific and wins over it.
374    #[must_use]
375    pub fn full_auto(mut self) -> Self {
376        self.full_auto = true;
377        self
378    }
379
380    /// Route approval requests through automatic review, using the
381    /// workspace-write sandbox (`--approve-for-me`).
382    ///
383    /// Added in `codex-cli` 0.147.0. Older releases reject it as an unexpected
384    /// argument, so this is the one builder method with a floor above the
385    /// wrapper's tested minimum. `codex exec review` and `codex exec resume`
386    /// do not accept it.
387    #[must_use]
388    pub fn approve_for_me(mut self) -> Self {
389        self.approve_for_me = true;
390        self
391    }
392
393    /// Bypass all approval prompts and sandbox restrictions.
394    ///
395    /// Passes `--dangerously-bypass-approvals-and-sandbox`. Use with caution.
396    #[must_use]
397    pub(crate) fn set_bypass_approvals_and_sandbox(mut self) -> Self {
398        self.dangerously_bypass_approvals_and_sandbox = true;
399        self
400    }
401
402    /// Change the working directory before running (`--cd <dir>`).
403    #[must_use]
404    pub fn cd(mut self, dir: impl Into<String>) -> Self {
405        self.cd = Some(dir.into());
406        self
407    }
408
409    /// Skip the git repository check (`--skip-git-repo-check`).
410    #[must_use]
411    pub fn skip_git_repo_check(mut self) -> Self {
412        self.skip_git_repo_check = true;
413        self
414    }
415
416    /// Add an extra directory to the context (`--add-dir <dir>`).
417    ///
418    /// May be called multiple times.
419    #[must_use]
420    pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
421        self.add_dirs.push(dir.into());
422        self
423    }
424
425    /// Run in ephemeral mode — no session is persisted (`--ephemeral`).
426    #[must_use]
427    pub fn ephemeral(mut self) -> Self {
428        self.ephemeral = true;
429        self
430    }
431
432    /// Require output to conform to a JSON schema (`--output-schema <path>`).
433    #[must_use]
434    pub fn output_schema(mut self, path: impl Into<String>) -> Self {
435        self.output_schema = Some(path.into());
436        self
437    }
438
439    /// Control terminal color output (`--color <mode>`).
440    #[must_use]
441    pub fn color(mut self, color: Color) -> Self {
442        self.color = Some(color);
443        self
444    }
445
446    /// Emit JSON Lines output (`--json`).
447    ///
448    /// When set, stdout will contain one JSON object per line. Use
449    /// [`execute_json_lines`](ExecCommand::execute_json_lines) to parse the
450    /// events automatically (requires the `json` feature).
451    #[must_use]
452    pub fn json(mut self) -> Self {
453        self.json = true;
454        self
455    }
456
457    /// Write the last assistant message to a file (`--output-last-message <path>`).
458    #[must_use]
459    pub fn output_last_message(mut self, path: impl Into<String>) -> Self {
460        self.output_last_message = Some(path.into());
461        self
462    }
463
464    /// Override the retry policy for this command.
465    ///
466    /// Takes precedence over the client-level policy set on [`Codex`].
467    #[must_use]
468    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
469        self.retry_policy = Some(policy);
470        self
471    }
472
473    /// Stream JSONL events from the command, invoking `handler` for each
474    /// parsed [`JsonLineEvent`] as it arrives.
475    ///
476    /// Automatically appends `--json` if not already set. Requires the `json`
477    /// feature.
478    ///
479    /// # Example
480    ///
481    /// ```no_run
482    /// use codex_wrapper::{Codex, ExecCommand, JsonLineEvent};
483    ///
484    /// # async fn example() -> codex_wrapper::Result<()> {
485    /// let codex = Codex::builder().build()?;
486    /// ExecCommand::new("what is 2+2?")
487    ///     .ephemeral()
488    ///     .stream(&codex, |event: JsonLineEvent| {
489    ///         println!("{}: {:?}", event.event_type, event.extra);
490    ///     })
491    ///     .await?;
492    /// # Ok(())
493    /// # }
494    /// ```
495    #[cfg(feature = "json")]
496    pub async fn stream<F>(&self, codex: &Codex, handler: F) -> Result<()>
497    where
498        F: FnMut(JsonLineEvent),
499    {
500        crate::streaming::stream_exec(codex, self, handler).await
501    }
502
503    /// Execute the command and parse the output as JSON Lines events.
504    ///
505    /// Automatically appends `--json` if not already set. Requires the `json`
506    /// feature.
507    #[cfg(feature = "json")]
508    pub async fn execute_json_lines(&self, codex: &Codex) -> Result<Vec<JsonLineEvent>> {
509        let mut args = self.args();
510        if !self.json {
511            args.push("--json".into());
512        }
513
514        let output = if self.prompt_via_stdin {
515            let prompt = self.prompt.as_deref().unwrap_or_default();
516            exec::run_codex_with_stdin_prompt(codex, args, prompt).await?
517        } else {
518            exec::run_codex_with_retry(codex, args, self.retry_policy.as_ref()).await?
519        };
520        parse_json_lines(&output.stdout)
521    }
522
523    /// Execute the command and return a typed [`QueryResult`].
524    ///
525    /// Assembles the final result text, ids, and token usage from the JSONL
526    /// event stream. Use [`execute_json_lines`](ExecCommand::execute_json_lines) for
527    /// the raw event stream. Requires the `json` feature.
528    #[cfg(feature = "json")]
529    pub async fn execute_json(&self, codex: &Codex) -> Result<QueryResult> {
530        let events = self.execute_json_lines(codex).await?;
531        Ok(QueryResult::from_events(events))
532    }
533}
534
535impl CodexCommand for ExecCommand {
536    type Output = CommandOutput;
537
538    fn args(&self) -> Vec<String> {
539        let mut args = vec!["exec".to_string()];
540
541        push_typed_config(&mut args, self.approval_policy, self.web_search);
542        push_repeat(&mut args, "-c", &self.config_overrides);
543        push_feature_toggles(
544            &mut args,
545            &self.enabled_features,
546            &self.disabled_features,
547            self.rollout_budget.is_some(),
548        );
549        if let Some(budget) = &self.rollout_budget {
550            args.push("-c".into());
551            args.push(budget.config_override());
552        }
553        push_repeat(&mut args, "--image", &self.images);
554
555        if let Some(model) = &self.model {
556            args.push("--model".into());
557            args.push(model.clone());
558        }
559        if self.oss {
560            args.push("--oss".into());
561        }
562        if let Some(local_provider) = &self.local_provider {
563            args.push("--local-provider".into());
564            args.push(local_provider.clone());
565        }
566        if let Some(sandbox) = effective_sandbox(self.sandbox, self.full_auto) {
567            args.push("--sandbox".into());
568            args.push(sandbox.as_arg().into());
569        }
570        if self.strict_config {
571            args.push("--strict-config".into());
572        }
573        if let Some(profile) = &self.profile {
574            args.push("--profile".into());
575            args.push(profile.clone());
576        }
577        if self.approve_for_me {
578            args.push("--approve-for-me".into());
579        }
580        if self.dangerously_bypass_approvals_and_sandbox {
581            args.push("--dangerously-bypass-approvals-and-sandbox".into());
582        }
583        if self.dangerously_bypass_hook_trust {
584            args.push("--dangerously-bypass-hook-trust".into());
585        }
586        if let Some(cd) = &self.cd {
587            args.push("--cd".into());
588            args.push(cd.clone());
589        }
590        if self.skip_git_repo_check {
591            args.push("--skip-git-repo-check".into());
592        }
593        push_repeat(&mut args, "--add-dir", &self.add_dirs);
594        if self.ephemeral {
595            args.push("--ephemeral".into());
596        }
597        if self.ignore_user_config {
598            args.push("--ignore-user-config".into());
599        }
600        if self.ignore_rules {
601            args.push("--ignore-rules".into());
602        }
603        if let Some(output_schema) = &self.output_schema {
604            args.push("--output-schema".into());
605            args.push(output_schema.clone());
606        }
607        if let Some(color) = self.color {
608            args.push("--color".into());
609            args.push(color.as_arg().into());
610        }
611        if self.json {
612            args.push("--json".into());
613        }
614        if let Some(path) = &self.output_last_message {
615            args.push("--output-last-message".into());
616            args.push(path.clone());
617        }
618        if self.prompt_via_stdin {
619            // The prompt travels on stdin; `-` is how the CLI is told to read
620            // it from there.
621            args.push("-".into());
622        } else if let Some(prompt) = &self.prompt {
623            args.push(prompt.clone());
624        }
625
626        args
627    }
628
629    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
630        if self.prompt_via_stdin {
631            let prompt = self.prompt.as_deref().unwrap_or_default();
632            return exec::run_codex_with_stdin_prompt(codex, self.args(), prompt).await;
633        }
634        exec::run_codex_with_retry(codex, self.args(), self.retry_policy.as_ref()).await
635    }
636}
637
638/// Resume a previous non-interactive session (`codex exec resume`).
639///
640/// Use [`session_id`](ExecResumeCommand::session_id) to target a specific
641/// session, or [`last`](ExecResumeCommand::last) to pick the most recent.
642#[derive(Debug, Clone)]
643pub struct ExecResumeCommand {
644    session_id: Option<String>,
645    prompt: Option<String>,
646    prompt_via_stdin: bool,
647    last: bool,
648    all: bool,
649    approval_policy: Option<ApprovalPolicyConfig>,
650    web_search: Option<WebSearchMode>,
651    config_overrides: Vec<String>,
652    enabled_features: Vec<String>,
653    disabled_features: Vec<String>,
654    rollout_budget: Option<RolloutBudgetConfig>,
655    images: Vec<String>,
656    model: Option<String>,
657    strict_config: bool,
658    dangerously_bypass_hook_trust: bool,
659    ignore_user_config: bool,
660    ignore_rules: bool,
661    output_schema: Option<String>,
662    full_auto: bool,
663    dangerously_bypass_approvals_and_sandbox: bool,
664    skip_git_repo_check: bool,
665    ephemeral: bool,
666    json: bool,
667    output_last_message: Option<String>,
668    retry_policy: Option<crate::retry::RetryPolicy>,
669}
670
671impl ExecResumeCommand {
672    /// Create a new resume command with no options set.
673    #[must_use]
674    pub fn new() -> Self {
675        Self {
676            session_id: None,
677            prompt: None,
678            prompt_via_stdin: false,
679            last: false,
680            all: false,
681            approval_policy: None,
682            web_search: None,
683            config_overrides: Vec::new(),
684            enabled_features: Vec::new(),
685            disabled_features: Vec::new(),
686            rollout_budget: None,
687            images: Vec::new(),
688            model: None,
689            strict_config: false,
690            dangerously_bypass_hook_trust: false,
691            ignore_user_config: false,
692            ignore_rules: false,
693            output_schema: None,
694            full_auto: false,
695            dangerously_bypass_approvals_and_sandbox: false,
696            skip_git_repo_check: false,
697            ephemeral: false,
698            json: false,
699            output_last_message: None,
700            retry_policy: None,
701        }
702    }
703
704    /// Resume a specific session by its ID.
705    #[must_use]
706    pub fn session_id(mut self, session_id: impl Into<String>) -> Self {
707        self.session_id = Some(session_id.into());
708        self
709    }
710
711    /// Append an additional prompt to the resumed session.
712    #[must_use]
713    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
714        self.prompt = Some(prompt.into());
715        self
716    }
717
718    /// Create a resume command whose prompt is delivered on stdin.
719    ///
720    /// The prompt is replaced by `-` in the argument list and written to the
721    /// child's stdin instead. Select the session separately with
722    /// [`session_id`](Self::session_id) or [`last`](Self::last).
723    #[must_use]
724    pub fn from_stdin(prompt: impl Into<String>) -> Self {
725        Self::new().prompt(prompt).prompt_via_stdin()
726    }
727
728    /// Deliver this command's prompt on stdin rather than in argv.
729    ///
730    /// Retry does not apply to a stdin prompt. Any policy set on the command
731    /// or client is ignored because replaying a consumed pipe would not be a
732    /// faithful retry.
733    #[must_use]
734    pub fn prompt_via_stdin(mut self) -> Self {
735        self.prompt_via_stdin = true;
736        self
737    }
738
739    /// The prompt to write to the child's stdin, if this command sends it
740    /// there. `None` when the prompt travels in argv.
741    #[cfg(feature = "json")]
742    pub(crate) fn stdin_prompt(&self) -> Option<&str> {
743        self.prompt_via_stdin
744            .then(|| self.prompt.as_deref().unwrap_or_default())
745    }
746
747    /// Resume the most recent session (`--last`).
748    #[must_use]
749    pub fn last(mut self) -> Self {
750        self.last = true;
751        self
752    }
753
754    /// Resume all sessions (`--all`).
755    #[must_use]
756    pub fn all(mut self) -> Self {
757        self.all = true;
758        self
759    }
760
761    /// Set the model to use (`--model <model>`).
762    ///
763    /// Panics if `model` is an empty string.
764    #[must_use]
765    pub fn model(mut self, model: impl Into<String>) -> Self {
766        let model = model.into();
767        assert!(!model.is_empty(), "model name must not be empty");
768        self.model = Some(model);
769        self
770    }
771
772    /// Attach an image to the prompt (`--image <path>`).
773    ///
774    /// May be called multiple times to attach several images.
775    #[must_use]
776    pub fn image(mut self, path: impl Into<String>) -> Self {
777        self.images.push(path.into());
778        self
779    }
780
781    /// Emit JSON Lines output (`--json`).
782    #[must_use]
783    pub fn json(mut self) -> Self {
784        self.json = true;
785        self
786    }
787
788    /// Write the last assistant message to a file (`--output-last-message <path>`).
789    #[must_use]
790    pub fn output_last_message(mut self, path: impl Into<String>) -> Self {
791        self.output_last_message = Some(path.into());
792        self
793    }
794
795    /// Override a config key (`-c key=value`).
796    ///
797    /// May be called multiple times to set several keys. Because `-c` is
798    /// last-wins, a key set here overrides the same key set by
799    /// [`approval_policy`](Self::approval_policy),
800    /// [`search_mode`](Self::search_mode), or [`full_auto`](Self::full_auto).
801    #[must_use]
802    pub fn config(mut self, key_value: impl Into<String>) -> Self {
803        self.config_overrides.push(key_value.into());
804        self
805    }
806
807    /// Set when the model asks for approval (`-c approval_policy="<value>"`).
808    ///
809    /// `codex-cli` 0.145.0 removed `--ask-for-approval` from the exec family;
810    /// the config key is the supported equivalent. Accepts an
811    /// [`ApprovalPolicy`](crate::ApprovalPolicy) directly, or an
812    /// [`ApprovalPolicyConfig`] for the two values the flag never took.
813    #[must_use]
814    pub fn approval_policy(mut self, policy: impl Into<ApprovalPolicyConfig>) -> Self {
815        self.approval_policy = Some(policy.into());
816        self
817    }
818
819    /// Enable live web search.
820    ///
821    /// Shorthand for `search_mode(WebSearchMode::Live)`, which is what the
822    /// removed `--search` flag meant.
823    #[must_use]
824    pub fn search(self) -> Self {
825        self.search_mode(WebSearchMode::Live)
826    }
827
828    /// Set the web search mode (`-c web_search="<value>"`).
829    ///
830    /// `codex-cli` 0.145.0 removed `--search` from the exec family; the config
831    /// key is the supported equivalent, and it is an enum rather than the
832    /// flag's boolean.
833    #[must_use]
834    pub fn search_mode(mut self, mode: WebSearchMode) -> Self {
835        self.web_search = Some(mode);
836        self
837    }
838
839    /// Enable an optional feature flag (`--enable <feature>`).
840    ///
841    /// May be called multiple times.
842    #[must_use]
843    pub fn enable(mut self, feature: impl Into<String>) -> Self {
844        self.enabled_features.push(feature.into());
845        self
846    }
847
848    /// Disable an optional feature flag (`--disable <feature>`).
849    ///
850    /// May be called multiple times.
851    #[must_use]
852    pub fn disable(mut self, feature: impl Into<String>) -> Self {
853        self.disabled_features.push(feature.into());
854        self
855    }
856
857    /// Enforce a Codex-native rollout-unit budget for this resumed execution.
858    ///
859    /// The meter and response-boundary overshoot are identical to
860    /// [`ExecCommand::rollout_budget`]. Emitting the same config on resume is
861    /// required: a budget applied only to the opening process does not carry
862    /// into a later `codex exec resume` process.
863    #[must_use]
864    pub fn rollout_budget(mut self, budget: RolloutBudgetConfig) -> Self {
865        self.rollout_budget = Some(budget);
866        self
867    }
868
869    /// Error on unrecognized config keys (`--strict-config`).
870    #[must_use]
871    pub fn strict_config(mut self) -> Self {
872        self.strict_config = true;
873        self
874    }
875
876    /// Bypass the hook trust prompt (`--dangerously-bypass-hook-trust`).
877    ///
878    /// Allows configured hooks to run without confirmation. Use with caution.
879    #[must_use]
880    pub(crate) fn set_bypass_hook_trust(mut self) -> Self {
881        self.dangerously_bypass_hook_trust = true;
882        self
883    }
884
885    /// Ignore the user-level config file (`--ignore-user-config`).
886    #[must_use]
887    pub fn ignore_user_config(mut self) -> Self {
888        self.ignore_user_config = true;
889        self
890    }
891
892    /// Ignore project rules files (`--ignore-rules`).
893    #[must_use]
894    pub fn ignore_rules(mut self) -> Self {
895        self.ignore_rules = true;
896        self
897    }
898
899    /// Require output to conform to a JSON schema (`--output-schema <path>`).
900    #[must_use]
901    pub fn output_schema(mut self, path: impl Into<String>) -> Self {
902        self.output_schema = Some(path.into());
903        self
904    }
905
906    /// Run in full-auto mode, emitted as `-c sandbox_mode="workspace-write"`.
907    ///
908    /// `--full-auto` is deprecated upstream; `codex-cli` 0.145.0 hides it and
909    /// warns to use `--sandbox workspace-write` instead. `codex exec resume`
910    /// has no `--sandbox` flag, so this sets the equivalent config key.
911    #[must_use]
912    pub fn full_auto(mut self) -> Self {
913        self.full_auto = true;
914        self
915    }
916
917    /// Bypass all approval prompts and sandbox restrictions.
918    ///
919    /// Passes `--dangerously-bypass-approvals-and-sandbox`. Use with caution.
920    #[must_use]
921    pub(crate) fn set_bypass_approvals_and_sandbox(mut self) -> Self {
922        self.dangerously_bypass_approvals_and_sandbox = true;
923        self
924    }
925
926    /// Skip the git repository check (`--skip-git-repo-check`).
927    #[must_use]
928    pub fn skip_git_repo_check(mut self) -> Self {
929        self.skip_git_repo_check = true;
930        self
931    }
932
933    /// Run in ephemeral mode — no session is persisted (`--ephemeral`).
934    #[must_use]
935    pub fn ephemeral(mut self) -> Self {
936        self.ephemeral = true;
937        self
938    }
939
940    /// Override the retry policy for this command.
941    ///
942    /// Takes precedence over the client-level policy set on [`Codex`].
943    #[must_use]
944    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
945        self.retry_policy = Some(policy);
946        self
947    }
948
949    /// Execute the command and parse the output as JSON Lines events.
950    ///
951    /// Automatically appends `--json` if not already set. Requires the `json`
952    /// feature.
953    #[cfg(feature = "json")]
954    pub async fn execute_json_lines(&self, codex: &Codex) -> Result<Vec<JsonLineEvent>> {
955        let mut args = self.args();
956        if !self.json {
957            args.push("--json".into());
958        }
959
960        let output = if self.prompt_via_stdin {
961            let prompt = self.prompt.as_deref().unwrap_or_default();
962            exec::run_codex_with_stdin_prompt(codex, args, prompt).await?
963        } else {
964            exec::run_codex_with_retry(codex, args, self.retry_policy.as_ref()).await?
965        };
966        parse_json_lines(&output.stdout)
967    }
968
969    /// Execute the resume command and return a typed [`QueryResult`].
970    ///
971    /// Assembles the final result text, ids, and token usage from the JSONL
972    /// event stream. Requires the `json` feature.
973    #[cfg(feature = "json")]
974    pub async fn execute_json(&self, codex: &Codex) -> Result<QueryResult> {
975        let events = self.execute_json_lines(codex).await?;
976        Ok(QueryResult::from_events(events))
977    }
978
979    /// Stream JSONL events from the resume command, invoking `handler` for
980    /// each parsed [`JsonLineEvent`] as it arrives.
981    ///
982    /// Automatically appends `--json` if not already set. Requires the `json`
983    /// feature.
984    #[cfg(feature = "json")]
985    pub async fn stream<F>(&self, codex: &Codex, handler: F) -> Result<()>
986    where
987        F: FnMut(JsonLineEvent),
988    {
989        crate::streaming::stream_exec_resume(codex, self, handler).await
990    }
991}
992
993impl Default for ExecResumeCommand {
994    fn default() -> Self {
995        Self::new()
996    }
997}
998
999impl CodexCommand for ExecResumeCommand {
1000    type Output = CommandOutput;
1001
1002    fn args(&self) -> Vec<String> {
1003        let mut args = vec!["exec".into(), "resume".into()];
1004        push_typed_config(&mut args, self.approval_policy, self.web_search);
1005        // `exec resume` has no `--sandbox` flag, so the `--full-auto`
1006        // replacement has to go through the config key.
1007        if self.full_auto {
1008            args.push("-c".into());
1009            args.push(format!(
1010                "sandbox_mode=\"{}\"",
1011                SandboxMode::WorkspaceWrite.as_arg()
1012            ));
1013        }
1014        push_repeat(&mut args, "-c", &self.config_overrides);
1015        push_feature_toggles(
1016            &mut args,
1017            &self.enabled_features,
1018            &self.disabled_features,
1019            self.rollout_budget.is_some(),
1020        );
1021        if let Some(budget) = &self.rollout_budget {
1022            args.push("-c".into());
1023            args.push(budget.config_override());
1024        }
1025        if self.last {
1026            args.push("--last".into());
1027        }
1028        if self.all {
1029            args.push("--all".into());
1030        }
1031        push_repeat(&mut args, "--image", &self.images);
1032        if let Some(model) = &self.model {
1033            args.push("--model".into());
1034            args.push(model.clone());
1035        }
1036        if self.strict_config {
1037            args.push("--strict-config".into());
1038        }
1039        if self.dangerously_bypass_approvals_and_sandbox {
1040            args.push("--dangerously-bypass-approvals-and-sandbox".into());
1041        }
1042        if self.dangerously_bypass_hook_trust {
1043            args.push("--dangerously-bypass-hook-trust".into());
1044        }
1045        if self.skip_git_repo_check {
1046            args.push("--skip-git-repo-check".into());
1047        }
1048        if self.ephemeral {
1049            args.push("--ephemeral".into());
1050        }
1051        if self.ignore_user_config {
1052            args.push("--ignore-user-config".into());
1053        }
1054        if self.ignore_rules {
1055            args.push("--ignore-rules".into());
1056        }
1057        if let Some(output_schema) = &self.output_schema {
1058            args.push("--output-schema".into());
1059            args.push(output_schema.clone());
1060        }
1061        if self.json {
1062            args.push("--json".into());
1063        }
1064        if let Some(path) = &self.output_last_message {
1065            args.push("--output-last-message".into());
1066            args.push(path.clone());
1067        }
1068        if let Some(session_id) = &self.session_id {
1069            args.push(session_id.clone());
1070        }
1071        if self.prompt_via_stdin {
1072            args.push("-".into());
1073        } else if let Some(prompt) = &self.prompt {
1074            args.push(prompt.clone());
1075        }
1076        args
1077    }
1078
1079    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
1080        if self.prompt_via_stdin {
1081            let prompt = self.prompt.as_deref().unwrap_or_default();
1082            return exec::run_codex_with_stdin_prompt(codex, self.args(), prompt).await;
1083        }
1084        exec::run_codex_with_retry(codex, self.args(), self.retry_policy.as_ref()).await
1085    }
1086}
1087
1088fn push_repeat(args: &mut Vec<String>, flag: &str, values: &[String]) {
1089    for value in values {
1090        args.push(flag.into());
1091        args.push(value.clone());
1092    }
1093}
1094
1095fn push_feature_toggles(
1096    args: &mut Vec<String>,
1097    enabled: &[String],
1098    disabled: &[String],
1099    protects_rollout_budget: bool,
1100) {
1101    let keep = |feature: &&String| !protects_rollout_budget || feature.as_str() != "rollout_budget";
1102    for feature in enabled.iter().filter(keep) {
1103        args.push("--enable".into());
1104        args.push(feature.clone());
1105    }
1106    for feature in disabled.iter().filter(keep) {
1107        args.push("--disable".into());
1108        args.push(feature.clone());
1109    }
1110}
1111
1112#[cfg(feature = "json")]
1113fn parse_json_lines(stdout: &str) -> Result<Vec<JsonLineEvent>> {
1114    stdout
1115        .lines()
1116        .filter(|line| line.trim_start().starts_with('{'))
1117        .map(|line| {
1118            serde_json::from_str(line).map_err(|source| Error::Json {
1119                message: format!("failed to parse JSONL event: {line}"),
1120                source,
1121            })
1122        })
1123        .collect()
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128    use super::*;
1129    use crate::types::ApprovalPolicy;
1130
1131    #[test]
1132    fn exec_args() {
1133        let args = ExecCommand::new("fix the test")
1134            .model("gpt-5")
1135            .sandbox(SandboxMode::WorkspaceWrite)
1136            .strict_config()
1137            .skip_git_repo_check()
1138            .ephemeral()
1139            .ignore_user_config()
1140            .ignore_rules()
1141            .json()
1142            .args();
1143
1144        assert_eq!(
1145            args,
1146            vec![
1147                "exec",
1148                "--model",
1149                "gpt-5",
1150                "--sandbox",
1151                "workspace-write",
1152                "--strict-config",
1153                "--skip-git-repo-check",
1154                "--ephemeral",
1155                "--ignore-user-config",
1156                "--ignore-rules",
1157                "--json",
1158                "fix the test",
1159            ]
1160        );
1161    }
1162
1163    #[test]
1164    fn exec_args_hook_trust() {
1165        let args = ExecCommand::new("go")
1166            .set_bypass_approvals_and_sandbox()
1167            .set_bypass_hook_trust()
1168            .args();
1169
1170        assert_eq!(
1171            args,
1172            vec![
1173                "exec",
1174                "--dangerously-bypass-approvals-and-sandbox",
1175                "--dangerously-bypass-hook-trust",
1176                "go",
1177            ]
1178        );
1179    }
1180
1181    #[test]
1182    #[should_panic(expected = "model name must not be empty")]
1183    fn exec_model_empty_panics() {
1184        let _ = ExecCommand::new("prompt").model("");
1185    }
1186
1187    #[test]
1188    #[should_panic(expected = "model name must not be empty")]
1189    fn exec_resume_model_empty_panics() {
1190        let _ = ExecResumeCommand::new().model("");
1191    }
1192
1193    #[test]
1194    fn exec_resume_args() {
1195        let args = ExecResumeCommand::new()
1196            .last()
1197            .model("gpt-5")
1198            .json()
1199            .prompt("continue")
1200            .args();
1201
1202        assert_eq!(
1203            args,
1204            vec![
1205                "exec", "resume", "--last", "--model", "gpt-5", "--json", "continue",
1206            ]
1207        );
1208    }
1209
1210    #[test]
1211    fn exec_resume_new_flags() {
1212        let args = ExecResumeCommand::new()
1213            .last()
1214            .strict_config()
1215            .set_bypass_hook_trust()
1216            .args();
1217
1218        assert_eq!(
1219            args,
1220            vec![
1221                "exec",
1222                "resume",
1223                "--last",
1224                "--strict-config",
1225                "--dangerously-bypass-hook-trust",
1226            ]
1227        );
1228    }
1229
1230    /// #53: `--ask-for-approval` and `--search` were removed from `codex exec`
1231    /// in codex-cli 0.145.0; the settings live on as config keys.
1232    #[test]
1233    fn exec_approval_and_search_emit_config_keys() {
1234        let args = ExecCommand::new("hi")
1235            .approval_policy(ApprovalPolicy::Never)
1236            .search()
1237            .args();
1238        assert_eq!(
1239            args,
1240            vec![
1241                "exec",
1242                "-c",
1243                "approval_policy=\"never\"",
1244                "-c",
1245                "web_search=\"live\"",
1246                "hi"
1247            ]
1248        );
1249        assert!(
1250            !args
1251                .iter()
1252                .any(|a| a == "--ask-for-approval" || a == "--search")
1253        );
1254    }
1255
1256    /// `granular` and `on-failure` are accepted by the config key but not by
1257    /// the flag, which is why `ApprovalPolicyConfig` exists.
1258    #[test]
1259    fn exec_approval_accepts_config_only_values() {
1260        let args = ExecCommand::new("hi")
1261            .approval_policy(ApprovalPolicyConfig::Granular)
1262            .args();
1263        assert_eq!(
1264            args,
1265            vec!["exec", "-c", "approval_policy=\"granular\"", "hi"]
1266        );
1267    }
1268
1269    #[test]
1270    fn exec_search_mode_variants() {
1271        for (mode, expected) in [
1272            (WebSearchMode::Disabled, "disabled"),
1273            (WebSearchMode::Cached, "cached"),
1274            (WebSearchMode::Indexed, "indexed"),
1275            (WebSearchMode::Live, "live"),
1276        ] {
1277            let args = ExecCommand::new("hi").search_mode(mode).args();
1278            assert_eq!(args[2], format!("web_search=\"{expected}\""));
1279        }
1280    }
1281
1282    /// `-c` is last-wins, so a raw override has to be emitted after the typed
1283    /// setters for it to take effect.
1284    #[test]
1285    fn exec_raw_config_is_emitted_after_typed_config() {
1286        let args = ExecCommand::new("hi")
1287            .approval_policy(ApprovalPolicy::Never)
1288            .config("approval_policy=\"untrusted\"")
1289            .args();
1290        let typed = args
1291            .iter()
1292            .position(|a| a == "approval_policy=\"never\"")
1293            .unwrap();
1294        let raw = args
1295            .iter()
1296            .position(|a| a == "approval_policy=\"untrusted\"")
1297            .unwrap();
1298        assert!(typed < raw, "raw override must win: {args:?}");
1299    }
1300
1301    #[test]
1302    fn native_rollout_budget_is_identical_and_final_on_open_and_resume() {
1303        let budget = RolloutBudgetConfig::builder(10_000)
1304            .reminder_at_remaining_tokens([5_000, 1_000])
1305            .sampling_token_weight(1.0)
1306            .prefill_token_weight(0.25)
1307            .build()
1308            .expect("valid budget");
1309        let expected = budget.config_override();
1310        let opening = ExecCommand::new("hi")
1311            .rollout_budget(budget.clone())
1312            .config("features.rollout_budget=false")
1313            .enable("rollout_budget")
1314            .disable("rollout_budget")
1315            .enable("keep-enabled")
1316            .disable("keep-disabled")
1317            .args();
1318        let resumed = ExecResumeCommand::new()
1319            .session_id("thread")
1320            .rollout_budget(budget)
1321            .config("features.rollout_budget=false")
1322            .enable("rollout_budget")
1323            .disable("rollout_budget")
1324            .enable("keep-enabled")
1325            .disable("keep-disabled")
1326            .args();
1327
1328        for args in [opening, resumed] {
1329            let budget_at = args.iter().position(|arg| arg == &expected).unwrap();
1330            let raw_at = args
1331                .iter()
1332                .position(|arg| arg == "features.rollout_budget=false")
1333                .unwrap();
1334            assert!(
1335                raw_at < budget_at,
1336                "native budget must beat raw config: {args:?}"
1337            );
1338            assert!(
1339                !args.windows(2).any(|pair| {
1340                    matches!(pair[0].as_str(), "--enable" | "--disable")
1341                        && pair[1] == "rollout_budget"
1342                }),
1343                "native budget must suppress conflicting feature toggles: {args:?}"
1344            );
1345            assert!(
1346                args.windows(2)
1347                    .any(|pair| pair == ["--enable", "keep-enabled"])
1348            );
1349            assert!(
1350                args.windows(2)
1351                    .any(|pair| pair == ["--disable", "keep-disabled"])
1352            );
1353        }
1354    }
1355
1356    /// #55: `--full-auto` is hidden and deprecated on the exec family.
1357    #[test]
1358    fn exec_full_auto_emits_sandbox_workspace_write() {
1359        let args = ExecCommand::new("hi").full_auto().args();
1360        assert_eq!(args, vec!["exec", "--sandbox", "workspace-write", "hi"]);
1361        assert!(!args.iter().any(|a| a == "--full-auto"));
1362    }
1363
1364    #[test]
1365    fn exec_explicit_sandbox_wins_over_full_auto() {
1366        let args = ExecCommand::new("hi")
1367            .full_auto()
1368            .sandbox(SandboxMode::ReadOnly)
1369            .args();
1370        assert_eq!(args, vec!["exec", "--sandbox", "read-only", "hi"]);
1371    }
1372
1373    /// `codex exec resume` has no `--sandbox` flag, so the replacement goes
1374    /// through the config key instead.
1375    #[test]
1376    fn exec_resume_full_auto_emits_sandbox_config_key() {
1377        let args = ExecResumeCommand::new().last().full_auto().args();
1378        assert_eq!(
1379            args,
1380            vec![
1381                "exec",
1382                "resume",
1383                "-c",
1384                "sandbox_mode=\"workspace-write\"",
1385                "--last"
1386            ]
1387        );
1388        assert!(!args.iter().any(|a| a == "--full-auto"));
1389    }
1390
1391    #[test]
1392    fn exec_resume_approval_and_search_emit_config_keys() {
1393        let args = ExecResumeCommand::new()
1394            .last()
1395            .approval_policy(ApprovalPolicyConfig::OnFailure)
1396            .search_mode(WebSearchMode::Cached)
1397            .args();
1398        assert_eq!(
1399            args,
1400            vec![
1401                "exec",
1402                "resume",
1403                "-c",
1404                "approval_policy=\"on-failure\"",
1405                "-c",
1406                "web_search=\"cached\"",
1407                "--last"
1408            ]
1409        );
1410    }
1411
1412    /// #65: these three were listed in #41 P1 but never landed on
1413    /// `ExecResumeCommand`.
1414    #[test]
1415    fn exec_resume_ignore_and_output_schema_args() {
1416        let args = ExecResumeCommand::new()
1417            .last()
1418            .ignore_user_config()
1419            .ignore_rules()
1420            .output_schema("/tmp/schema.json")
1421            .args();
1422        assert_eq!(
1423            args,
1424            vec![
1425                "exec",
1426                "resume",
1427                "--last",
1428                "--ignore-user-config",
1429                "--ignore-rules",
1430                "--output-schema",
1431                "/tmp/schema.json"
1432            ]
1433        );
1434    }
1435
1436    /// #81: the builder emitted `codex exec -` while every spawn path closed
1437    /// the child's stdin, so the prompt was never delivered. This drives a
1438    /// fake codex that echoes back what it read, which is the only way to see
1439    /// the difference: the argv is identical either way.
1440    #[cfg(all(unix, feature = "json"))]
1441    #[tokio::test]
1442    async fn stdin_prompt_reaches_the_child() {
1443        let codex = echoing_stdin_codex();
1444        let prompt = "a prompt too awkward for argv\nwith a second line";
1445
1446        let result = ExecCommand::from_stdin(prompt)
1447            .execute_json(&codex)
1448            .await
1449            .unwrap();
1450
1451        assert_eq!(result.result, prompt);
1452    }
1453
1454    #[cfg(unix)]
1455    #[tokio::test]
1456    async fn resume_stdin_prompt_reaches_the_child_for_raw_execution() {
1457        let codex = echoing_stdin_codex();
1458        let prompt = "raw resumed stdin prompt";
1459
1460        let output = ExecResumeCommand::from_stdin(prompt)
1461            .session_id("thread-1")
1462            .execute(&codex)
1463            .await
1464            .unwrap();
1465
1466        assert!(output.stdout.contains(prompt));
1467    }
1468
1469    #[cfg(all(unix, feature = "json"))]
1470    #[tokio::test]
1471    async fn resume_stdin_prompt_reaches_the_child_for_json_execution() {
1472        let codex = echoing_stdin_codex();
1473        let prompt = "json resumed stdin prompt";
1474
1475        let result = ExecResumeCommand::from_stdin(prompt)
1476            .session_id("thread-1")
1477            .execute_json(&codex)
1478            .await
1479            .unwrap();
1480
1481        assert_eq!(result.result, prompt);
1482    }
1483
1484    /// The same delivery, on the streaming path, which pipes stdin separately.
1485    #[cfg(all(unix, feature = "json"))]
1486    #[tokio::test]
1487    async fn stdin_prompt_reaches_the_child_when_streaming() {
1488        let codex = echoing_stdin_codex();
1489        let prompt = "streamed stdin prompt";
1490        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1491        let sink = std::sync::Arc::clone(&seen);
1492
1493        ExecCommand::from_stdin(prompt)
1494            .stream(&codex, move |event| {
1495                if let Some(text) = event.agent_message_text() {
1496                    sink.lock().unwrap().push(text);
1497                }
1498            })
1499            .await
1500            .unwrap();
1501
1502        assert_eq!(seen.lock().unwrap().as_slice(), &[prompt.to_string()]);
1503    }
1504
1505    #[cfg(all(unix, feature = "json"))]
1506    #[tokio::test]
1507    async fn resume_stdin_prompt_reaches_the_child_when_streaming() {
1508        let codex = echoing_stdin_codex();
1509        let prompt = "streamed resumed stdin prompt";
1510        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1511        let sink = std::sync::Arc::clone(&seen);
1512
1513        ExecResumeCommand::from_stdin(prompt)
1514            .session_id("thread-1")
1515            .stream(&codex, move |event| {
1516                if let Some(text) = event.agent_message_text() {
1517                    sink.lock().unwrap().push(text);
1518                }
1519            })
1520            .await
1521            .unwrap();
1522
1523        assert_eq!(seen.lock().unwrap().as_slice(), &[prompt.to_string()]);
1524    }
1525
1526    /// A prompt larger than a pipe buffer must not deadlock: the write and the
1527    /// output drain have to run concurrently.
1528    #[cfg(all(unix, feature = "json"))]
1529    #[tokio::test]
1530    async fn a_prompt_larger_than_the_pipe_buffer_still_completes() {
1531        let codex = echoing_stdin_codex();
1532        // Well past the usual 64 KiB pipe capacity.
1533        let prompt = "x".repeat(512 * 1024);
1534
1535        let result = ExecCommand::from_stdin(&prompt)
1536            .execute_json(&codex)
1537            .await
1538            .unwrap();
1539
1540        assert_eq!(result.result.len(), prompt.len());
1541    }
1542
1543    #[cfg(unix)]
1544    fn echoing_stdin_codex() -> Codex {
1545        let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1546            .join("tests")
1547            .join("fake-codex-echo-stdin.sh");
1548        Codex::builder()
1549            .binary("/bin/bash")
1550            .arg(script.to_str().unwrap())
1551            .build()
1552            .expect("bash must exist")
1553    }
1554
1555    #[test]
1556    fn from_stdin_emits_the_dash_positional_not_the_prompt() {
1557        let args = ExecCommand::from_stdin("secret prompt").ephemeral().args();
1558        assert_eq!(args, vec!["exec", "--ephemeral", "-"]);
1559        // The prompt must not leak into argv, which is the whole point of
1560        // sending it on stdin.
1561        assert!(!args.iter().any(|a| a.contains("secret")));
1562    }
1563
1564    #[test]
1565    fn prompt_via_stdin_converts_an_existing_prompt() {
1566        let args = ExecCommand::new("hello").prompt_via_stdin().args();
1567        assert_eq!(args, vec!["exec", "-"]);
1568    }
1569
1570    #[test]
1571    fn resume_from_stdin_emits_the_dash_positional_not_the_prompt() {
1572        let args = ExecResumeCommand::from_stdin("secret prompt")
1573            .session_id("thread-1")
1574            .ephemeral()
1575            .args();
1576        assert_eq!(args, vec!["exec", "resume", "--ephemeral", "thread-1", "-"]);
1577        assert!(!args.iter().any(|arg| arg.contains("secret")));
1578    }
1579
1580    #[test]
1581    fn resume_prompt_via_stdin_converts_an_existing_prompt() {
1582        let args = ExecResumeCommand::new()
1583            .session_id("thread-1")
1584            .prompt("hello")
1585            .prompt_via_stdin()
1586            .args();
1587        assert_eq!(args, vec!["exec", "resume", "thread-1", "-"]);
1588    }
1589}