Skip to main content

ai_memory/cli/
wrap.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory wrap <agent>` — cross-platform Rust replacement for the
5//! shell wrappers PR-1 of issue #487 shipped in the integration recipes.
6//!
7//! ## What it does
8//!
9//! 1. Calls `cli::boot::run` in-process, capturing its stdout into a
10//!    buffer. No subprocess; no shell. The `--no-boot` flag skips this
11//!    step so a misconfigured DB path doesn't block the agent.
12//! 2. Builds a system-context string of the form
13//!    `<preamble>\n\n<boot output>` where the preamble explains to the
14//!    downstream agent that it has ai-memory access.
15//! 3. Spawns the wrapped agent (`std::process::Command`) with the
16//!    system-context delivered via the chosen strategy:
17//!    - `SystemFlag` — `<agent> <flag> "<system_msg>" <trailing args...>`
18//!    - `SystemEnv`  — `<env_name>=<system_msg> <agent> <trailing args...>`
19//!    - `MessageFile` — write `<system_msg>` to a `NamedTempFile`, pass
20//!      `<flag> <tempfile_path>` to the agent, drop the tempfile on
21//!      exit so it is cleaned up by the OS.
22//!    - `Auto` — resolved at runtime from a built-in lookup table
23//!      (`default_strategy`).
24//! 4. Forwards the parent's stdin / stdout / stderr unmodified
25//!    (`Stdio::inherit`).
26//! 5. Returns the wrapped agent's exit code as the wrap subcommand's
27//!    exit code, so wrappers compose cleanly with shell pipelines and
28//!    CI gates that branch on `$?`.
29//!
30//! ## Why Rust, not bash + PowerShell
31//!
32//! The user directive on issue #487 PR-6 was: implementation should be
33//! predominantly Rust with config hooks. PR-1 shipped per-recipe bash
34//! and PowerShell wrappers, which doubled the maintenance surface and
35//! couldn't run in restricted Windows / containerized environments
36//! without a shell. A single cross-platform Rust subcommand eliminates
37//! both problems — it's the same code path on macOS / Linux / Windows
38//! / Docker / Kubernetes / Nix / etc.
39//!
40//! ## Lookup table
41//!
42//! `default_strategy(agent)` resolves the unflagged form `ai-memory
43//! wrap <agent> -- <args>` to the right delivery mechanism for the
44//! agents we can identify by name today. Unknown agents fall through to
45//! `--system <msg>` because that's the most common contract across
46//! OpenAI-compatible CLIs. Future PRs (notably PR-7) can extend the
47//! table by adding match arms.
48
49use crate::cli::CliOutput;
50use crate::cli::boot::{self, BootArgs};
51use anyhow::{Context, Result};
52use clap::Args;
53use std::ffi::OsStr;
54use std::io::Write;
55use std::path::Path;
56use std::process::{Command, Stdio};
57
58/// Default budget for the inner `ai-memory boot` call when the caller
59/// doesn't override. Mirrors `cli::boot::DEFAULT_BUDGET_TOKENS` but is
60/// re-declared here so wrap can tune independently if needed.
61const DEFAULT_WRAP_BUDGET_TOKENS: usize = 4096;
62
63/// Default row limit for the inner boot call. Same value `cli::boot`
64/// itself defaults to.
65const DEFAULT_WRAP_LIMIT: usize = 10;
66
67/// Preamble injected before the boot output in every wrap call.
68/// Explains to the downstream agent why it's seeing this context. Kept
69/// short and stable so prompt-cache breakpoints upstream stay warm.
70const WRAP_PREAMBLE: &str = "You have access to ai-memory, a persistent memory system. \
71The recent context loaded for you appears below. Reference it when relevant to the user's request.";
72
73/// Strategy for delivering the assembled system message to the wrapped
74/// agent. Each variant maps to a distinct CLI ABI an agent might
75/// expose.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum WrapStrategy {
78    /// Pass the system message as the value of a CLI flag, e.g.
79    /// `codex --system "<msg>" <args...>`.
80    SystemFlag {
81        /// The flag name including any leading dashes — e.g. `--system`,
82        /// `--system-prompt`, `-s`.
83        flag: String,
84    },
85    /// Set the system message as an environment variable for the child
86    /// process. e.g. `OLLAMA_SYSTEM=<msg> ollama run hermes3:8b`.
87    SystemEnv {
88        /// The env var name, e.g. `OLLAMA_SYSTEM`.
89        name: String,
90    },
91    /// Write the system message to a tempfile and pass the path via a
92    /// CLI flag. e.g. `aider --message-file <path> <args...>`. Used by
93    /// agents whose system-message length exceeds shell argv limits or
94    /// whose CLI explicitly takes a file path.
95    MessageFile {
96        /// The flag that takes the file path, e.g. `--message-file`.
97        flag: String,
98    },
99    /// Resolve the strategy at runtime from `default_strategy(agent)`.
100    /// This is the natural mode when the user hasn't passed any of the
101    /// strategy override flags.
102    Auto,
103}
104
105/// Built-in agent → strategy lookup. The list is small by design — we
106/// only encode strategies for agents we've actually verified. Anything
107/// not in the table falls through to `--system <msg>` because that's
108/// the most common contract across OpenAI-compatible CLIs.
109///
110/// PR-7 may extend this map; the matrix is intentionally tabular so
111/// adding a row is a one-line change.
112#[must_use]
113pub fn default_strategy(agent: &str) -> WrapStrategy {
114    match agent {
115        // OpenAI Codex CLI. The flag name varies between Codex variants
116        // (`--system`, `--system-prompt`, `OPENAI_CLI_SYSTEM`) but
117        // `--system` is the documented form on the upstream codex-cli
118        // crate (PR-1 recipe + Codex CLI README). Users running a
119        // variant that exposes a different flag can override with
120        // `--system-flag <flag>`.
121        "codex" | "codex-cli" => WrapStrategy::SystemFlag {
122            flag: "--system".into(),
123        },
124        // Aider takes its system / instructions input from a file via
125        // `--message-file`. Aider's CLI explicitly recommends this for
126        // anything longer than a one-liner because it doesn't shell-quote
127        // the arg-form for newlines reliably.
128        "aider" => WrapStrategy::MessageFile {
129            flag: "--message-file".into(),
130        },
131        // Google Gemini CLI. `--system` is the documented prepend form.
132        "gemini" => WrapStrategy::SystemFlag {
133            flag: "--system".into(),
134        },
135        // Ollama uses an env var because `ollama run <model>` doesn't
136        // expose a `--system` flag at the CLI level — it expects the
137        // system prompt either inside the prompt body or via the
138        // `OLLAMA_SYSTEM` env var (also the form `ollama serve` reads).
139        "ollama" => WrapStrategy::SystemEnv {
140            name: "OLLAMA_SYSTEM".into(),
141        },
142        // Default: most OpenAI-compatible CLIs accept `--system <msg>`.
143        // If that's wrong, users override with `--system-flag` /
144        // `--system-env` / `--message-file-flag`.
145        _ => WrapStrategy::SystemFlag {
146            flag: "--system".into(),
147        },
148    }
149}
150
151/// Args for `ai-memory wrap`. Designed so the simplest form
152/// (`ai-memory wrap codex -- "hello"`) just works — every flag has a
153/// defaulted value or the lookup table fills it in.
154#[derive(Args, Debug)]
155pub struct WrapArgs {
156    /// Name of the agent CLI to wrap, e.g. `codex`, `aider`, `gemini`,
157    /// `ollama`. Resolved against `default_strategy` to pick the
158    /// system-message delivery mechanism unless the user overrides
159    /// with one of the strategy flags below. The agent name is also
160    /// the executable looked up on `$PATH`.
161    pub agent: String,
162
163    /// Override the system-message flag (e.g. `--system-prompt`). When
164    /// set, wrap delivers the system message via this flag regardless
165    /// of what the lookup table says for `<agent>`.
166    #[arg(long, value_name = "FLAG")]
167    pub system_flag: Option<String>,
168
169    /// Override the system-message env var (e.g. `OPENAI_CLI_SYSTEM`).
170    /// Mutually exclusive with `--system-flag` and
171    /// `--message-file-flag`; if multiple are set, the last specified
172    /// on the command line wins (clap default), but the most common
173    /// case is supplying exactly one.
174    #[arg(long, value_name = "NAME", conflicts_with_all = ["system_flag", "message_file_flag"])]
175    pub system_env: Option<String>,
176
177    /// Override the message-file flag (e.g. `--message-file`). Wrap
178    /// will write the system message to a tempfile and pass this flag
179    /// + the tempfile path to the agent. The tempfile is cleaned up on
180    /// wrap exit (cross-platform; uses `tempfile::NamedTempFile`).
181    #[arg(long, value_name = "FLAG", conflicts_with_all = ["system_flag", "system_env"])]
182    pub message_file_flag: Option<String>,
183
184    /// Skip the inner `ai-memory boot` call entirely. The wrapped
185    /// agent runs without any prepended memory context. Useful when
186    /// the DB is known to be unavailable, when the user wants the wrap
187    /// subcommand for argv-forwarding only, or for tests that want to
188    /// isolate the wrapping behavior from the boot-loading behavior.
189    #[arg(long, default_value_t = false)]
190    pub no_boot: bool,
191
192    /// Row limit forwarded to the inner `ai-memory boot --limit`.
193    /// Clamped to `[1, 50]` by `cli::boot` itself.
194    #[arg(long, default_value_t = DEFAULT_WRAP_LIMIT)]
195    pub limit: usize,
196
197    /// Approximate token budget forwarded to the inner
198    /// `ai-memory boot --budget-tokens`.
199    #[arg(long, default_value_t = DEFAULT_WRAP_BUDGET_TOKENS)]
200    pub budget_tokens: usize,
201
202    /// Trailing arguments forwarded verbatim to the wrapped agent CLI
203    /// after the system-message delivery (the convention is to
204    /// separate them with `--` on the command line:
205    /// `ai-memory wrap codex -- chat --model gpt-5`).
206    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
207    pub trailing: Vec<String>,
208}
209
210/// Resolve the active strategy from the user-supplied overrides plus
211/// the built-in lookup table. Order of precedence:
212///
213/// 1. `--system-env <name>` → `SystemEnv`
214/// 2. `--message-file-flag <flag>` → `MessageFile`
215/// 3. `--system-flag <flag>` → `SystemFlag`
216/// 4. fall through to `default_strategy(agent)` (the lookup table)
217fn resolve_strategy(args: &WrapArgs) -> WrapStrategy {
218    if let Some(name) = args.system_env.as_deref() {
219        return WrapStrategy::SystemEnv { name: name.into() };
220    }
221    if let Some(flag) = args.message_file_flag.as_deref() {
222        return WrapStrategy::MessageFile { flag: flag.into() };
223    }
224    if let Some(flag) = args.system_flag.as_deref() {
225        return WrapStrategy::SystemFlag { flag: flag.into() };
226    }
227    default_strategy(&args.agent)
228}
229
230/// Run `cli::boot::run` in-process, capturing its stdout into a
231/// `Vec<u8>`. Stderr is also captured but discarded — the boot helper
232/// already honors `--quiet` for us, so any stderr that escapes is by
233/// design (a developer-facing diagnostic).
234///
235/// On any boot failure, this function returns an empty `String` rather
236/// than propagating — the agent should still run even if memory load
237/// fails. The user-facing diagnostic header is already on stdout in
238/// that case (`# ai-memory boot: warn — db unavailable …`) so the
239/// caller still sees what happened.
240fn run_boot_capture(
241    db_path: &Path,
242    limit: usize,
243    budget_tokens: usize,
244    app_config: &crate::config::AppConfig,
245) -> String {
246    let mut stdout: Vec<u8> = Vec::new();
247    let mut stderr: Vec<u8> = Vec::new();
248    let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
249    let args = BootArgs {
250        namespace: None,
251        limit,
252        budget_tokens,
253        format: "text".to_string(),
254        no_header: false,
255        // --quiet so a missing DB never blocks the wrapped agent.
256        quiet: true,
257        cwd: None,
258    };
259    if boot::run(db_path, &args, app_config, &mut out).is_err() {
260        // Even on hard failure (which `cli::boot::run` should never
261        // hit thanks to the `--quiet` graceful path), return an empty
262        // string so the agent runs unwrapped rather than getting a
263        // blocking error.
264        return String::new();
265    }
266    String::from_utf8(stdout).unwrap_or_default()
267}
268
269/// Assemble the `<preamble>\n\n<boot_output>` system message. Trims
270/// trailing whitespace on the boot section to keep the assembled
271/// string tidy in the agent's prompt.
272fn build_system_message(boot_output: &str) -> String {
273    let trimmed = boot_output.trim_end();
274    if trimmed.is_empty() {
275        // Even with an empty body the preamble is still useful — it
276        // tells the agent "you have memory access" so it knows it can
277        // call `memory_recall` mid-session if it has the tool.
278        WRAP_PREAMBLE.to_string()
279    } else {
280        format!("{WRAP_PREAMBLE}\n\n{trimmed}")
281    }
282}
283
284/// Spawn the agent with stdio inherited and return the exit code.
285/// Wrapped here so tests can assert on the spawned-command shape via
286/// the helpers in `#[cfg(test)] mod tests`.
287fn spawn_and_wait(mut cmd: Command) -> Result<i32> {
288    cmd.stdin(Stdio::inherit())
289        .stdout(Stdio::inherit())
290        .stderr(Stdio::inherit());
291    let status = cmd
292        .status()
293        .with_context(|| format!("ai-memory wrap: failed to spawn agent {cmd:?}"))?;
294    // Unix: `code()` is None when the child was killed by a signal.
295    // We then surface 128+sig per the standard shell convention so the
296    // caller can branch on the signal in CI scripts.
297    let code = if let Some(c) = status.code() {
298        c
299    } else {
300        #[cfg(unix)]
301        {
302            use std::os::unix::process::ExitStatusExt;
303            status.signal().map_or(1, |s| 128 + s)
304        }
305        #[cfg(not(unix))]
306        {
307            1
308        }
309    };
310    Ok(code)
311}
312
313/// Build the `Command` for an agent given a strategy. Pulled out of
314/// `run` so the tests can assert directly on the resulting `Command`'s
315/// argv / env without spawning a subprocess.
316///
317/// Returns the assembled `Command` + (when the strategy is
318/// `MessageFile`) the `NamedTempFile` whose lifetime governs cleanup.
319/// The caller MUST keep the returned `Option<NamedTempFile>` alive
320/// until after the child has exited; dropping it sooner unlinks the
321/// file mid-spawn on platforms where unlink-while-open is permitted.
322fn build_command_for_strategy(
323    agent: &str,
324    strategy: &WrapStrategy,
325    system_msg: &str,
326    trailing: &[String],
327) -> Result<(Command, Option<tempfile::NamedTempFile>)> {
328    let mut cmd = Command::new(agent);
329    let mut tempfile_handle: Option<tempfile::NamedTempFile> = None;
330    match strategy {
331        WrapStrategy::SystemFlag { flag } => {
332            cmd.arg(flag).arg(system_msg);
333            for t in trailing {
334                cmd.arg(t);
335            }
336        }
337        WrapStrategy::SystemEnv { name } => {
338            cmd.env(name, system_msg);
339            for t in trailing {
340                cmd.arg(t);
341            }
342        }
343        WrapStrategy::MessageFile { flag } => {
344            // `tempfile::NamedTempFile` is cross-platform: on Unix it's
345            // a regular file with a randomised name; on Windows it
346            // skips the unlink-while-open trick (which Windows
347            // disallows) and cleans up on `Drop`. Either way the file
348            // is gone after wrap exits.
349            let mut tf = tempfile::NamedTempFile::new()
350                .context("ai-memory wrap: failed to create system-message tempfile")?;
351            tf.write_all(system_msg.as_bytes())
352                .context("ai-memory wrap: failed to write system-message tempfile")?;
353            // Flush so the agent process reads the full message even
354            // if the OS hasn't drained the buffer yet.
355            tf.flush()
356                .context("ai-memory wrap: failed to flush system-message tempfile")?;
357            cmd.arg(flag).arg(tf.path().as_os_str());
358            for t in trailing {
359                cmd.arg(t);
360            }
361            tempfile_handle = Some(tf);
362        }
363        WrapStrategy::Auto => {
364            // Resolve and recurse. `Auto` should be handled by
365            // `resolve_strategy` before we get here, but if a caller
366            // synthesises a `WrapArgs` programmatically and leaves
367            // strategy as `Auto`, fall through to the lookup table.
368            let resolved = default_strategy(agent);
369            return build_command_for_strategy(agent, &resolved, system_msg, trailing);
370        }
371    }
372    Ok((cmd, tempfile_handle))
373}
374
375/// `ai-memory wrap` entry point. Returns the wrapped agent's exit code
376/// so `daemon_runtime` can `std::process::exit(code)` on a non-zero
377/// outcome — that's how shell pipelines and CI gates branch on the
378/// agent's success.
379///
380/// # Errors
381///
382/// - The wrapped agent binary cannot be spawned (`Command::status`
383///   surfaces the OS-level error).
384/// - `tempfile::NamedTempFile::new()` fails when the strategy is
385///   `MessageFile` (very rare; `/tmp` full or unwritable).
386pub fn run(
387    db_path: &Path,
388    args: &WrapArgs,
389    app_config: &crate::config::AppConfig,
390    _out: &mut CliOutput<'_>,
391) -> Result<i32> {
392    let strategy = resolve_strategy(args);
393
394    // Boot context. `--no-boot` skips it so the agent runs unwrapped
395    // (still through `Command::new(agent)` so this subcommand stays
396    // useful as a strategy-hooked launcher even with memory off).
397    let system_msg = if args.no_boot {
398        WRAP_PREAMBLE.to_string()
399    } else {
400        let boot_output = run_boot_capture(db_path, args.limit, args.budget_tokens, app_config);
401        build_system_message(&boot_output)
402    };
403
404    let (cmd, _tempfile_handle) =
405        build_command_for_strategy(&args.agent, &strategy, &system_msg, &args.trailing)?;
406
407    // _tempfile_handle is held by the local binding so it lives until
408    // after `spawn_and_wait` returns. Don't shorten its scope.
409    let code = spawn_and_wait(cmd)?;
410    Ok(code)
411}
412
413/// Public helper for callers (tests + future PR-7 recipe additions)
414/// that want to format an `OsStr` argv element back to UTF-8 for
415/// assertions / logging. Falls back to the lossy form so platforms
416/// with non-UTF-8 paths don't panic.
417#[must_use]
418pub fn os_str_to_string_lossy(s: &OsStr) -> String {
419    s.to_string_lossy().into_owned()
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::cli::test_utils::{TestEnv, seed_memory};
426
427    fn default_args(agent: &str) -> WrapArgs {
428        WrapArgs {
429            agent: agent.to_string(),
430            system_flag: None,
431            system_env: None,
432            message_file_flag: None,
433            no_boot: false,
434            limit: DEFAULT_WRAP_LIMIT,
435            budget_tokens: DEFAULT_WRAP_BUDGET_TOKENS,
436            trailing: Vec::new(),
437        }
438    }
439
440    #[test]
441    fn wrap_resolves_default_strategy_per_known_agent() {
442        assert_eq!(
443            default_strategy("codex"),
444            WrapStrategy::SystemFlag {
445                flag: "--system".into()
446            }
447        );
448        assert_eq!(
449            default_strategy("codex-cli"),
450            WrapStrategy::SystemFlag {
451                flag: "--system".into()
452            }
453        );
454        assert_eq!(
455            default_strategy("aider"),
456            WrapStrategy::MessageFile {
457                flag: "--message-file".into()
458            }
459        );
460        assert_eq!(
461            default_strategy("gemini"),
462            WrapStrategy::SystemFlag {
463                flag: "--system".into()
464            }
465        );
466        assert_eq!(
467            default_strategy("ollama"),
468            WrapStrategy::SystemEnv {
469                name: "OLLAMA_SYSTEM".into()
470            }
471        );
472        // Unknown agent → fall through to --system.
473        assert_eq!(
474            default_strategy("some-future-cli"),
475            WrapStrategy::SystemFlag {
476                flag: "--system".into()
477            }
478        );
479    }
480
481    #[test]
482    fn resolve_strategy_explicit_overrides_lookup_table() {
483        let mut args = default_args("ollama");
484        args.system_flag = Some("--system-prompt".into());
485        // Even though "ollama" maps to SystemEnv in the lookup,
486        // explicit `--system-flag` wins.
487        assert_eq!(
488            resolve_strategy(&args),
489            WrapStrategy::SystemFlag {
490                flag: "--system-prompt".into()
491            }
492        );
493    }
494
495    #[test]
496    fn resolve_strategy_env_override_takes_precedence_over_flag_default() {
497        let mut args = default_args("codex");
498        args.system_env = Some("OPENAI_CLI_SYSTEM".into());
499        assert_eq!(
500            resolve_strategy(&args),
501            WrapStrategy::SystemEnv {
502                name: "OPENAI_CLI_SYSTEM".into()
503            }
504        );
505    }
506
507    #[test]
508    fn resolve_strategy_message_file_override() {
509        let mut args = default_args("codex");
510        args.message_file_flag = Some("--prompt-file".into());
511        assert_eq!(
512            resolve_strategy(&args),
513            WrapStrategy::MessageFile {
514                flag: "--prompt-file".into()
515            }
516        );
517    }
518
519    #[test]
520    fn build_system_message_prepends_preamble() {
521        let msg = build_system_message("- [mid/abc] hello");
522        assert!(msg.starts_with(WRAP_PREAMBLE));
523        assert!(msg.contains("hello"));
524        assert!(msg.contains("\n\n"), "preamble + body separator missing");
525    }
526
527    #[test]
528    fn build_system_message_empty_body_returns_preamble_only() {
529        let msg = build_system_message("");
530        assert_eq!(msg, WRAP_PREAMBLE);
531    }
532
533    #[test]
534    fn build_system_message_strips_trailing_whitespace() {
535        let msg = build_system_message("body line\n\n\n");
536        assert!(msg.ends_with("body line"));
537    }
538
539    #[test]
540    fn build_command_system_flag_sets_argv_correctly() {
541        let strat = WrapStrategy::SystemFlag {
542            flag: "--system".into(),
543        };
544        let trailing = vec![
545            "chat".to_string(),
546            "--model".to_string(),
547            "gpt-5".to_string(),
548        ];
549        let (cmd, tf) =
550            build_command_for_strategy("codex", &strat, "SYS-MSG-VALUE", &trailing).unwrap();
551        assert!(tf.is_none(), "SystemFlag must not allocate a tempfile");
552        let argv: Vec<String> = cmd.get_args().map(|s| os_str_to_string_lossy(s)).collect();
553        assert_eq!(
554            argv,
555            vec!["--system", "SYS-MSG-VALUE", "chat", "--model", "gpt-5"]
556        );
557        // Verify the program name (first arg of Command, not in
558        // get_args) — get_program is part of the std API.
559        assert_eq!(cmd.get_program(), OsStr::new("codex"));
560    }
561
562    #[test]
563    fn build_command_system_env_sets_env_var_and_omits_flag() {
564        let strat = WrapStrategy::SystemEnv {
565            name: "OLLAMA_SYSTEM".into(),
566        };
567        let trailing = vec!["run".to_string(), "hermes3:8b".to_string()];
568        let (cmd, tf) =
569            build_command_for_strategy("ollama", &strat, "SYS-ENV-MSG", &trailing).unwrap();
570        assert!(tf.is_none(), "SystemEnv must not allocate a tempfile");
571        let argv: Vec<String> = cmd.get_args().map(|s| os_str_to_string_lossy(s)).collect();
572        // The env-var strategy never injects a flag — argv is just the
573        // trailing args.
574        assert_eq!(argv, vec!["run", "hermes3:8b"]);
575        // Confirm OLLAMA_SYSTEM is set on the Command's env. get_envs()
576        // yields (key, Option<value>) pairs.
577        let env_pairs: Vec<(String, Option<String>)> = cmd
578            .get_envs()
579            .map(|(k, v)| {
580                (
581                    os_str_to_string_lossy(k),
582                    v.map(|x| os_str_to_string_lossy(x)),
583                )
584            })
585            .collect();
586        let entry = env_pairs
587            .iter()
588            .find(|(k, _)| k == "OLLAMA_SYSTEM")
589            .expect("OLLAMA_SYSTEM must be set");
590        assert_eq!(entry.1.as_deref(), Some("SYS-ENV-MSG"));
591    }
592
593    #[test]
594    fn wrap_strategy_message_file_creates_tempfile_and_cleans_up() {
595        let strat = WrapStrategy::MessageFile {
596            flag: "--message-file".into(),
597        };
598        let (path_owned, exists_during) = {
599            let (cmd, tf) =
600                build_command_for_strategy("aider", &strat, "FILE-MSG-CONTENT", &[]).unwrap();
601            let tf = tf.expect("MessageFile must allocate a tempfile");
602            // The argv should point at the tempfile path. We can't
603            // directly assert path equality on Windows (canonicalisation
604            // differs), so just check the `--message-file` flag is the
605            // first arg and the second arg is some non-empty path.
606            let argv: Vec<String> = cmd.get_args().map(|s| os_str_to_string_lossy(s)).collect();
607            assert_eq!(argv.len(), 2);
608            assert_eq!(argv[0], "--message-file");
609            assert!(!argv[1].is_empty());
610            // Sanity: the tempfile contains the expected message body.
611            let read_back = std::fs::read_to_string(tf.path()).unwrap();
612            assert_eq!(read_back, "FILE-MSG-CONTENT");
613            let exists = tf.path().exists();
614            // Take the path as PathBuf BEFORE dropping `tf` so we can
615            // re-stat after the block exits.
616            let p = tf.path().to_path_buf();
617            (p, exists)
618        };
619        assert!(
620            exists_during,
621            "tempfile must exist while NamedTempFile is alive"
622        );
623        // After the block ends, NamedTempFile is dropped, which
624        // unlinks the file (Unix and Windows both — tempfile crate
625        // smooths over the platform difference).
626        assert!(
627            !path_owned.exists(),
628            "tempfile must be cleaned up on Drop, but {} still exists",
629            path_owned.display()
630        );
631    }
632
633    #[test]
634    fn wrap_with_unreachable_db_does_not_block_agent() {
635        // Boot honors `--quiet` and exits 0 with a warn header on stdout
636        // when the DB is missing. The captured stdout becomes the body
637        // of the wrap system message. We assert: (a) `run_boot_capture`
638        // returns *something* (the warn header) without erroring, and
639        // (b) the assembled system message still carries the preamble
640        // so the agent knows it has memory access (even if empty).
641        let env = TestEnv::fresh();
642        let bad = env
643            .db_path
644            .parent()
645            .unwrap()
646            .join("nope/that/does/not/exist/db.sqlite");
647        let captured = run_boot_capture(
648            &bad,
649            10,
650            DEFAULT_WRAP_BUDGET_TOKENS,
651            &crate::config::AppConfig::default(),
652        );
653        assert!(
654            captured.contains("# ai-memory boot: warn"),
655            "wrap should surface the warn header even with unreachable DB: {captured}"
656        );
657        let assembled = build_system_message(&captured);
658        assert!(assembled.starts_with(WRAP_PREAMBLE));
659        assert!(assembled.contains("warn"));
660    }
661
662    #[test]
663    fn wrap_with_no_boot_skips_context() {
664        // Smoke: the run path with `no_boot = true` produces a system
665        // message that's exactly the preamble (no boot body). We verify
666        // by re-running the equivalent assembly the `run` function uses
667        // when `args.no_boot` is true.
668        let mut args = default_args("codex");
669        args.no_boot = true;
670        // The `run` body's `if args.no_boot { WRAP_PREAMBLE.to_string() }`
671        // branch is what produces the system message in this mode.
672        // We replicate it here so we can assert on the value without
673        // spawning a subprocess (the real `codex` isn't on the test
674        // host's PATH).
675        let system_msg = if args.no_boot {
676            WRAP_PREAMBLE.to_string()
677        } else {
678            unreachable!()
679        };
680        assert_eq!(system_msg, WRAP_PREAMBLE);
681        // And the assembled command for that message must contain
682        // exactly the preamble as the flag value, no boot context.
683        let (cmd, _tf) = build_command_for_strategy(
684            &args.agent,
685            &resolve_strategy(&args),
686            &system_msg,
687            &args.trailing,
688        )
689        .unwrap();
690        let argv: Vec<String> = cmd.get_args().map(|s| os_str_to_string_lossy(s)).collect();
691        assert_eq!(argv.len(), 2);
692        assert_eq!(argv[0], "--system");
693        assert_eq!(argv[1], WRAP_PREAMBLE);
694    }
695
696    #[test]
697    fn wrap_injects_system_message_via_flag() {
698        // Seed a memory so the boot output is non-empty, then assert
699        // the assembled system message that wrap would pass to the
700        // agent contains both the preamble AND the seeded memory's
701        // title. This is the contract the docs/integrations recipes
702        // depend on.
703        let env = TestEnv::fresh();
704        seed_memory(&env.db_path, "ns-wrap-test", "wrap-injection-canary", "x");
705        let captured = run_boot_capture(
706            &env.db_path,
707            10,
708            DEFAULT_WRAP_BUDGET_TOKENS,
709            &crate::config::AppConfig::default(),
710        );
711        // boot::run sets the namespace from auto_namespace, which won't
712        // match `ns-wrap-test` unless cwd is set. The fallback path
713        // should still surface SOMETHING so the captured body is
714        // non-empty (warn or info header at minimum).
715        assert!(
716            !captured.is_empty(),
717            "expected non-empty boot capture, got empty"
718        );
719        let assembled = build_system_message(&captured);
720        assert!(assembled.starts_with(WRAP_PREAMBLE));
721        assert!(assembled.len() > WRAP_PREAMBLE.len());
722        // Now assert the assembled message rides through to the
723        // command's argv.
724        let (cmd, _tf) = build_command_for_strategy(
725            "codex",
726            &WrapStrategy::SystemFlag {
727                flag: "--system".into(),
728            },
729            &assembled,
730            &[],
731        )
732        .unwrap();
733        let argv: Vec<String> = cmd.get_args().map(|s| os_str_to_string_lossy(s)).collect();
734        assert_eq!(argv.len(), 2);
735        assert_eq!(argv[0], "--system");
736        assert!(argv[1].starts_with(WRAP_PREAMBLE));
737    }
738
739    #[test]
740    fn wrap_passes_through_exit_code_via_status_propagation() {
741        // We can't assume any specific binary is on PATH, but we can
742        // exercise the propagation logic with a guaranteed-available
743        // command: `false` on Unix exits 1, `true` exits 0. On Windows
744        // we use `cmd /C exit N`.
745        #[cfg(unix)]
746        {
747            // Exit 0
748            let cmd = Command::new("true");
749            let code = spawn_and_wait(cmd).unwrap();
750            assert_eq!(code, 0);
751            // Exit 1
752            let cmd = Command::new("false");
753            let code = spawn_and_wait(cmd).unwrap();
754            assert_eq!(code, 1);
755        }
756        #[cfg(windows)]
757        {
758            let mut cmd = Command::new("cmd");
759            cmd.args(["/C", "exit", "0"]);
760            let code = spawn_and_wait(cmd).unwrap();
761            assert_eq!(code, 0);
762            let mut cmd = Command::new("cmd");
763            cmd.args(["/C", "exit", "7"]);
764            let code = spawn_and_wait(cmd).unwrap();
765            assert_eq!(code, 7);
766        }
767    }
768
769    #[test]
770    fn wrap_run_returns_exit_code_for_real_subprocess() {
771        // End-to-end: drive `run` itself (not just the helpers). We
772        // wrap a known-good binary (`true` on unix, `cmd /C exit` on
773        // windows) and assert the returned code matches.
774        let mut env = TestEnv::fresh();
775        let db_path = env.db_path.clone();
776        let mut out = env.output();
777        #[cfg(unix)]
778        {
779            let mut args = default_args("true");
780            // Skip boot to avoid touching the DB and to keep the test
781            // deterministic. `--system "..."` is still passed to the
782            // agent — `true` ignores all argv, exits 0.
783            args.no_boot = true;
784            let code = run(
785                &db_path,
786                &args,
787                &crate::config::AppConfig::default(),
788                &mut out,
789            )
790            .unwrap();
791            assert_eq!(code, 0);
792        }
793        #[cfg(windows)]
794        {
795            let mut args = default_args("cmd");
796            args.no_boot = true;
797            // We override the strategy to SystemFlag with a no-op flag
798            // that `cmd /C` will ignore alongside the system message,
799            // then a real /C exit. Easier: override via system_env so
800            // no flag is added, then trailing carries `/C exit 5`.
801            args.system_env = Some("WRAP_DUMMY".into());
802            args.trailing = vec!["/C".into(), "exit".into(), "5".into()];
803            let code = run(
804                &db_path,
805                &args,
806                &crate::config::AppConfig::default(),
807                &mut out,
808            )
809            .unwrap();
810            assert_eq!(code, 5);
811        }
812    }
813
814    #[test]
815    fn auto_strategy_resolves_at_command_build_time() {
816        // Exercise the `WrapStrategy::Auto` recursive branch in
817        // `build_command_for_strategy`.
818        let (cmd, tf) = build_command_for_strategy(
819            "codex",
820            &WrapStrategy::Auto,
821            "AUTO-MSG",
822            &["chat".to_string()],
823        )
824        .unwrap();
825        assert!(tf.is_none());
826        let argv: Vec<String> = cmd.get_args().map(|s| os_str_to_string_lossy(s)).collect();
827        // codex auto-resolves to SystemFlag{--system}.
828        assert_eq!(argv, vec!["--system", "AUTO-MSG", "chat"]);
829    }
830
831    #[test]
832    fn auto_strategy_resolves_to_message_file_for_aider() {
833        let (cmd, tf) =
834            build_command_for_strategy("aider", &WrapStrategy::Auto, "AIDER-MSG", &[]).unwrap();
835        // aider auto-resolves to MessageFile, so a tempfile must be
836        // allocated.
837        assert!(tf.is_some());
838        let argv: Vec<String> = cmd.get_args().map(|s| os_str_to_string_lossy(s)).collect();
839        assert_eq!(argv.len(), 2);
840        assert_eq!(argv[0], "--message-file");
841    }
842
843    #[test]
844    fn run_boot_capture_returns_string_not_panics_on_missing_db() {
845        // Hardening: every error path inside boot must surface as a
846        // String (possibly empty, possibly the warn header) — never a
847        // panic — so the wrapped agent always runs.
848        let env = TestEnv::fresh();
849        let bad = env
850            .db_path
851            .parent()
852            .unwrap()
853            .join("__definitely_missing__/db");
854        let s = run_boot_capture(
855            &bad,
856            10,
857            DEFAULT_WRAP_BUDGET_TOKENS,
858            &crate::config::AppConfig::default(),
859        );
860        // Either the warn header or empty (both are non-panic outcomes).
861        assert!(
862            s.is_empty() || s.contains("# ai-memory boot:"),
863            "expected warn header or empty, got: {s}"
864        );
865    }
866}