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}