Skip to main content

agent_playground/
runner.rs

1//! Runtime execution for launching an agent inside a temporary playground.
2//!
3//! The runner copies playground files into a throwaway directory, executes the
4//! selected agent command in that directory, and optionally persists the final
5//! state as a snapshot.
6
7use std::{
8    fs,
9    io::{self, BufRead, IsTerminal, Write},
10    path::{Path, PathBuf},
11    process::{self, Command as ProcessCommand},
12    time::{SystemTime, UNIX_EPOCH},
13};
14
15use anyhow::{Context, Result, bail};
16use dotenvy::Error as DotenvError;
17use tempfile::tempdir;
18
19use crate::config::{AppConfig, PlaygroundDefinition};
20
21const DOTENV_FILE_NAME: &str = ".env";
22const DEFAULT_PLAYGROUND_ID: &str = "__default__";
23
24/// Runs a configured playground with the selected agent command.
25///
26/// The execution flow is:
27///
28/// 1. Resolve the playground and agent command from [`AppConfig`].
29/// 2. Copy playground contents into a temporary directory.
30/// 3. Optionally load `.env` key-value pairs into the child process.
31/// 4. Run the agent command in the temporary directory.
32/// 5. Optionally save a snapshot of that directory on normal exit.
33///
34/// When `selected_agent_id` is `None`, the module falls back to the
35/// playground default agent and then to the root default agent.
36///
37/// # Returns
38///
39/// Returns the exit code that should be used by the caller process.
40///
41/// # Errors
42///
43/// Returns an error if configuration references are invalid, filesystem
44/// operations fail, environment parsing fails, or the agent process cannot be
45/// started or yields an unrepresentable status.
46pub fn run_playground(
47    config: &AppConfig,
48    playground_id: &str,
49    selected_agent_id: Option<&str>,
50    save_on_exit: bool,
51) -> Result<i32> {
52    let playground = config
53        .playgrounds
54        .get(playground_id)
55        .with_context(|| format!("unknown playground '{playground_id}'"))?;
56    let playground_config = config.resolve_playground_config(playground)?;
57    let agent_id = selected_agent_id.unwrap_or(&playground_config.default_agent);
58    let agent_command = config
59        .agents
60        .get(agent_id)
61        .with_context(|| format!("unknown agent '{agent_id}'"))?;
62    let load_env = playground_config.load_env;
63
64    let temp_dir = tempdir().context("failed to create temporary playground directory")?;
65    copy_playground_contents(playground, load_env, temp_dir.path())?;
66    let playground_env = load_playground_env(playground, load_env)?;
67
68    run_agent_in_directory(
69        temp_dir.path(),
70        agent_id,
71        agent_command,
72        playground_env,
73        save_on_exit,
74        &config.saved_playgrounds_dir,
75        playground_id,
76    )
77}
78
79/// Runs the selected agent inside an empty temporary playground directory.
80///
81/// This is similar to [`run_playground`], but it does not require a configured
82/// playground template and starts from an empty working directory instead.
83pub fn run_default_playground(
84    config: &AppConfig,
85    selected_agent_id: Option<&str>,
86    save_on_exit: bool,
87) -> Result<i32> {
88    let default_agent = config
89        .playground_defaults
90        .default_agent
91        .as_deref()
92        .context("default playground config is missing default_agent")?;
93    let agent_id = selected_agent_id.unwrap_or(default_agent);
94    let agent_command = config
95        .agents
96        .get(agent_id)
97        .with_context(|| format!("unknown agent '{agent_id}'"))?;
98
99    let temp_dir = tempdir().context("failed to create temporary playground directory")?;
100
101    run_agent_in_directory(
102        temp_dir.path(),
103        agent_id,
104        agent_command,
105        Vec::new(),
106        save_on_exit,
107        &config.saved_playgrounds_dir,
108        DEFAULT_PLAYGROUND_ID,
109    )
110}
111
112fn run_agent_in_directory(
113    working_dir: &Path,
114    agent_id: &str,
115    agent_command: &str,
116    playground_env: Vec<(String, String)>,
117    save_on_exit: bool,
118    saved_playgrounds_dir: &Path,
119    playground_id: &str,
120) -> Result<i32> {
121    let status = build_agent_command(agent_command)
122        .envs(playground_env)
123        .current_dir(working_dir)
124        .status()
125        .with_context(|| format!("failed to start agent '{agent_id}'"))?;
126
127    let (exit_code, exited_normally) = exit_code_from_status(status)?;
128
129    let should_save = should_save_playground_snapshot(exited_normally, save_on_exit)
130        || (should_prompt_to_save_playground_snapshot(
131            exited_normally,
132            save_on_exit,
133            is_interactive_terminal(),
134        ) && prompt_to_save_playground_snapshot(io::stdin().lock(), &mut io::stdout().lock())?);
135
136    if should_save {
137        let saved_path =
138            save_playground_snapshot(working_dir, saved_playgrounds_dir, playground_id)?;
139        println!("saved playground snapshot to {}", saved_path.display());
140    }
141
142    Ok(exit_code)
143}
144
145fn should_save_playground_snapshot(exited_normally: bool, save_on_exit: bool) -> bool {
146    exited_normally && save_on_exit
147}
148
149fn should_prompt_to_save_playground_snapshot(
150    exited_normally: bool,
151    save_on_exit: bool,
152    is_interactive: bool,
153) -> bool {
154    exited_normally && !save_on_exit && is_interactive
155}
156
157fn is_interactive_terminal() -> bool {
158    io::stdin().is_terminal() && io::stdout().is_terminal()
159}
160
161fn prompt_to_save_playground_snapshot<R: BufRead, W: Write>(
162    mut input: R,
163    output: &mut W,
164) -> Result<bool> {
165    write!(output, "Keep temporary playground copy? [y/N] ")
166        .context("failed to write save prompt")?;
167    output.flush().context("failed to flush save prompt")?;
168
169    let mut response = String::new();
170    input
171        .read_line(&mut response)
172        .context("failed to read save prompt response")?;
173
174    let normalized = response.trim().to_ascii_lowercase();
175    Ok(matches!(normalized.as_str(), "y" | "yes"))
176}
177
178fn save_playground_snapshot(
179    source_dir: &Path,
180    saved_playgrounds_dir: &Path,
181    playground_id: &str,
182) -> Result<PathBuf> {
183    fs::create_dir_all(saved_playgrounds_dir)
184        .with_context(|| format!("failed to create {}", saved_playgrounds_dir.display()))?;
185
186    let destination = next_saved_playground_dir(saved_playgrounds_dir, playground_id);
187    fs::create_dir_all(&destination)
188        .with_context(|| format!("failed to create {}", destination.display()))?;
189    copy_directory_contents(source_dir, &destination)?;
190
191    Ok(destination)
192}
193
194fn next_saved_playground_dir(saved_playgrounds_dir: &Path, playground_id: &str) -> PathBuf {
195    let timestamp = SystemTime::now()
196        .duration_since(UNIX_EPOCH)
197        .unwrap_or_default()
198        .as_secs();
199    let base_name = format!("{playground_id}-{timestamp}");
200    let mut candidate = saved_playgrounds_dir.join(&base_name);
201    let mut suffix = 1;
202
203    while candidate.exists() {
204        candidate = saved_playgrounds_dir.join(format!("{base_name}-{suffix}"));
205        suffix += 1;
206    }
207
208    candidate
209}
210
211fn copy_playground_contents(
212    playground: &PlaygroundDefinition,
213    load_env: bool,
214    destination: &Path,
215) -> Result<()> {
216    for entry in fs::read_dir(&playground.directory)
217        .with_context(|| format!("failed to read {}", playground.directory.display()))?
218    {
219        let entry = entry.with_context(|| {
220            format!(
221                "failed to inspect an entry under {}",
222                playground.directory.display()
223            )
224        })?;
225        let source_path = entry.path();
226
227        if should_skip_playground_path(playground, load_env, &source_path) {
228            continue;
229        }
230
231        copy_path(&source_path, &destination.join(entry.file_name()))?;
232    }
233
234    Ok(())
235}
236
237fn should_skip_playground_path(
238    playground: &PlaygroundDefinition,
239    load_env: bool,
240    source_path: &Path,
241) -> bool {
242    source_path == playground.config_file
243        || (load_env
244            && source_path
245                .file_name()
246                .is_some_and(|name| name == DOTENV_FILE_NAME))
247}
248
249fn load_playground_env(
250    playground: &PlaygroundDefinition,
251    load_env: bool,
252) -> Result<Vec<(String, String)>> {
253    if !load_env {
254        return Ok(Vec::new());
255    }
256
257    let env_path = playground.directory.join(DOTENV_FILE_NAME);
258    match dotenvy::from_path_iter(&env_path) {
259        Ok(entries) => entries
260            .collect::<std::result::Result<Vec<_>, _>>()
261            .with_context(|| format!("failed to parse {}", env_path.display())),
262        Err(DotenvError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
263            Ok(Vec::new())
264        }
265        Err(error) => Err(error).with_context(|| format!("failed to load {}", env_path.display())),
266    }
267}
268
269fn copy_directory_contents(source: &Path, destination: &Path) -> Result<()> {
270    for entry in
271        fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
272    {
273        let entry = entry
274            .with_context(|| format!("failed to inspect an entry under {}", source.display()))?;
275        copy_path(&entry.path(), &destination.join(entry.file_name()))?;
276    }
277
278    Ok(())
279}
280
281fn copy_path(source: &Path, destination: &Path) -> Result<()> {
282    let metadata = fs::symlink_metadata(source)
283        .with_context(|| format!("failed to inspect {}", source.display()))?;
284
285    if metadata.is_dir() {
286        fs::create_dir_all(destination)
287            .with_context(|| format!("failed to create {}", destination.display()))?;
288
289        for entry in
290            fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
291        {
292            let entry = entry.with_context(|| {
293                format!("failed to inspect an entry under {}", source.display())
294            })?;
295            copy_path(&entry.path(), &destination.join(entry.file_name()))?;
296        }
297
298        return Ok(());
299    }
300
301    if metadata.is_file() {
302        if let Some(parent) = destination.parent() {
303            fs::create_dir_all(parent)
304                .with_context(|| format!("failed to create {}", parent.display()))?;
305        }
306
307        fs::copy(source, destination).with_context(|| {
308            format!(
309                "failed to copy {} to {}",
310                source.display(),
311                destination.display()
312            )
313        })?;
314        return Ok(());
315    }
316
317    bail!(
318        "unsupported file type while copying playground contents: {}",
319        source.display()
320    );
321}
322
323fn build_agent_command(agent_command: &str) -> ProcessCommand {
324    #[cfg(windows)]
325    {
326        let mut command = ProcessCommand::new("cmd");
327        command.arg("/C").arg(agent_command);
328        command
329    }
330
331    #[cfg(not(windows))]
332    {
333        let mut command = ProcessCommand::new("sh");
334        command.arg("-c").arg(agent_command);
335        command
336    }
337}
338
339fn exit_code_from_status(status: process::ExitStatus) -> Result<(i32, bool)> {
340    if let Some(code) = status.code() {
341        return Ok((code, code == 0));
342    }
343
344    #[cfg(unix)]
345    {
346        use std::os::unix::process::ExitStatusExt;
347
348        if let Some(signal) = status.signal() {
349            return Ok((128 + signal, false));
350        }
351    }
352
353    bail!("agent process ended without an exit code")
354}
355
356#[cfg(test)]
357mod tests {
358    use std::{collections::BTreeMap, fs, path::Path};
359
360    use anyhow::Result;
361    use tempfile::tempdir;
362
363    use crate::config::{AppConfig, ConfigPaths, PlaygroundConfig, PlaygroundDefinition};
364
365    use super::{
366        copy_playground_contents, exit_code_from_status, prompt_to_save_playground_snapshot,
367        run_default_playground, run_playground, save_playground_snapshot,
368        should_prompt_to_save_playground_snapshot, should_save_playground_snapshot,
369    };
370
371    #[cfg(unix)]
372    fn command_writing_marker(marker: &str) -> String {
373        format!("printf '{marker}' > agent.txt && test ! -e apg.toml")
374    }
375
376    #[cfg(windows)]
377    fn command_writing_marker(marker: &str) -> String {
378        format!("echo {marker}>agent.txt && if exist apg.toml exit /b 1")
379    }
380
381    #[cfg(unix)]
382    fn failing_command() -> String {
383        "printf 'failed' > agent.txt; exit 7".to_string()
384    }
385
386    #[cfg(windows)]
387    fn failing_command() -> String {
388        "echo failed>agent.txt & exit /b 7".to_string()
389    }
390
391    #[cfg(unix)]
392    fn command_recording_env(var_name: &str) -> String {
393        format!("printf '%s' \"${var_name}\" > env.txt && test ! -e .env && test ! -e apg.toml")
394    }
395
396    #[cfg(windows)]
397    fn command_recording_env(var_name: &str) -> String {
398        format!(
399            "powershell -NoProfile -Command \"[System.IO.File]::WriteAllText('env.txt', $env:{var_name})\" && if exist .env exit /b 1 && if exist apg.toml exit /b 1"
400        )
401    }
402
403    fn single_saved_snapshot(save_root: &Path) -> Result<std::path::PathBuf> {
404        let snapshots = fs::read_dir(save_root)?
405            .collect::<std::result::Result<Vec<_>, _>>()?
406            .into_iter()
407            .map(|entry| entry.path())
408            .collect::<Vec<_>>();
409
410        assert_eq!(snapshots.len(), 1);
411        Ok(snapshots.into_iter().next().expect("single snapshot"))
412    }
413
414    fn make_playground(
415        source_dir: &Path,
416        playground_id: &str,
417        default_agent: Option<&str>,
418        load_env: Option<bool>,
419    ) -> Result<PlaygroundDefinition> {
420        let config_file = source_dir.join("apg.toml");
421        fs::write(&config_file, "description = 'ignored'")?;
422        fs::write(source_dir.join("notes.txt"), "hello")?;
423
424        Ok(PlaygroundDefinition {
425            id: playground_id.to_string(),
426            description: "demo".to_string(),
427            directory: source_dir.to_path_buf(),
428            config_file,
429            playground: PlaygroundConfig {
430                default_agent: default_agent.map(str::to_string),
431                load_env,
432            },
433        })
434    }
435
436    fn make_config(
437        source_dir: &Path,
438        save_root: &Path,
439        playground_id: &str,
440        default_agent: Option<&str>,
441        playground_default_agent: Option<&str>,
442        playground_load_env: Option<bool>,
443        agents: &[(&str, String)],
444    ) -> Result<AppConfig> {
445        let playground = make_playground(
446            source_dir,
447            playground_id,
448            playground_default_agent,
449            playground_load_env,
450        )?;
451        let agents = agents
452            .iter()
453            .map(|(id, command)| ((*id).to_string(), command.clone()))
454            .collect::<BTreeMap<_, _>>();
455        let mut playgrounds = BTreeMap::new();
456        playgrounds.insert(playground_id.to_string(), playground);
457
458        Ok(AppConfig {
459            paths: ConfigPaths::from_root_dir(source_dir.join("config-root")),
460            agents,
461            saved_playgrounds_dir: save_root.to_path_buf(),
462            playground_defaults: PlaygroundConfig {
463                default_agent: default_agent.map(str::to_string),
464                load_env: Some(false),
465            },
466            playgrounds,
467        })
468    }
469
470    #[test]
471    fn copies_playground_contents_except_config_file() -> Result<()> {
472        let source_dir = tempdir()?;
473        let destination_dir = tempdir()?;
474        let nested_dir = source_dir.path().join("nested");
475        let config_file = source_dir.path().join("apg.toml");
476        let note_file = source_dir.path().join("notes.txt");
477        let nested_file = nested_dir.join("task.md");
478
479        fs::create_dir_all(&nested_dir)?;
480        fs::write(&config_file, "description = 'ignored'")?;
481        fs::write(&note_file, "hello")?;
482        fs::write(&nested_file, "nested")?;
483
484        let playground = PlaygroundDefinition {
485            id: "demo".to_string(),
486            description: "demo".to_string(),
487            directory: source_dir.path().to_path_buf(),
488            config_file: config_file.clone(),
489            playground: PlaygroundConfig::default(),
490        };
491
492        copy_playground_contents(&playground, false, destination_dir.path())?;
493
494        assert!(!destination_dir.path().join("apg.toml").exists());
495        assert_eq!(
496            fs::read_to_string(destination_dir.path().join("notes.txt"))?,
497            "hello"
498        );
499        assert_eq!(
500            fs::read_to_string(destination_dir.path().join("nested").join("task.md"))?,
501            "nested"
502        );
503
504        Ok(())
505    }
506
507    #[test]
508    fn skips_dotenv_file_when_load_env_is_enabled() -> Result<()> {
509        let source_dir = tempdir()?;
510        let destination_dir = tempdir()?;
511        let config_file = source_dir.path().join("apg.toml");
512        let env_file = source_dir.path().join(".env");
513
514        fs::write(&config_file, "description = 'ignored'")?;
515        fs::write(source_dir.path().join("notes.txt"), "hello")?;
516        fs::write(&env_file, "API_TOKEN=secret\n")?;
517
518        let playground = PlaygroundDefinition {
519            id: "demo".to_string(),
520            description: "demo".to_string(),
521            directory: source_dir.path().to_path_buf(),
522            config_file,
523            playground: PlaygroundConfig::default(),
524        };
525
526        copy_playground_contents(&playground, true, destination_dir.path())?;
527
528        assert!(!destination_dir.path().join(".env").exists());
529        assert_eq!(
530            fs::read_to_string(destination_dir.path().join("notes.txt"))?,
531            "hello"
532        );
533
534        Ok(())
535    }
536
537    #[test]
538    fn saves_snapshot_only_for_normal_exit_when_enabled() {
539        assert!(should_save_playground_snapshot(true, true));
540        assert!(!should_save_playground_snapshot(true, false));
541        assert!(!should_save_playground_snapshot(false, true));
542        assert!(!should_save_playground_snapshot(false, false));
543    }
544
545    #[test]
546    fn prompts_only_for_normal_exit_without_explicit_save_flag() {
547        assert!(should_prompt_to_save_playground_snapshot(true, false, true));
548        assert!(!should_prompt_to_save_playground_snapshot(true, true, true));
549        assert!(!should_prompt_to_save_playground_snapshot(
550            false, false, true
551        ));
552        assert!(!should_prompt_to_save_playground_snapshot(
553            true, false, false
554        ));
555    }
556
557    #[test]
558    fn prompt_accepts_yes_and_rejects_default_enter() -> Result<()> {
559        let mut output = Vec::new();
560        let should_save =
561            prompt_to_save_playground_snapshot(std::io::Cursor::new("y\n"), &mut output)?;
562        assert!(should_save);
563        assert_eq!(
564            String::from_utf8(output).expect("prompt output"),
565            "Keep temporary playground copy? [y/N] "
566        );
567
568        let mut output = Vec::new();
569        let should_save =
570            prompt_to_save_playground_snapshot(std::io::Cursor::new("\n"), &mut output)?;
571        assert!(!should_save);
572
573        Ok(())
574    }
575
576    #[test]
577    fn saves_temporary_playground_snapshot() -> Result<()> {
578        let source_dir = tempdir()?;
579        let save_root = tempdir()?;
580        let nested_dir = source_dir.path().join("nested");
581
582        fs::create_dir_all(&nested_dir)?;
583        fs::write(source_dir.path().join("notes.txt"), "hello")?;
584        fs::write(nested_dir.join("task.md"), "nested")?;
585
586        let saved_path = save_playground_snapshot(source_dir.path(), save_root.path(), "demo")?;
587
588        assert!(saved_path.starts_with(save_root.path()));
589        assert_eq!(fs::read_to_string(saved_path.join("notes.txt"))?, "hello");
590        assert_eq!(
591            fs::read_to_string(saved_path.join("nested").join("task.md"))?,
592            "nested"
593        );
594
595        Ok(())
596    }
597
598    #[test]
599    fn only_zero_exit_code_counts_as_normal_exit() -> Result<()> {
600        #[cfg(unix)]
601        {
602            use std::os::unix::process::ExitStatusExt;
603
604            let success = std::process::ExitStatus::from_raw(0);
605            let interrupted = std::process::ExitStatus::from_raw(130 << 8);
606
607            assert_eq!(exit_code_from_status(success)?, (0, true));
608            assert_eq!(exit_code_from_status(interrupted)?, (130, false));
609        }
610
611        #[cfg(windows)]
612        {
613            use std::os::windows::process::ExitStatusExt;
614
615            let success = std::process::ExitStatus::from_raw(0);
616            let interrupted = std::process::ExitStatus::from_raw(130);
617
618            assert_eq!(exit_code_from_status(success)?, (0, true));
619            assert_eq!(exit_code_from_status(interrupted)?, (130, false));
620        }
621
622        Ok(())
623    }
624
625    #[test]
626    fn errors_for_unknown_playground() -> Result<()> {
627        let source_dir = tempdir()?;
628        let save_root = tempdir()?;
629        let config = make_config(
630            source_dir.path(),
631            save_root.path(),
632            "demo",
633            Some("claude"),
634            None,
635            None,
636            &[("claude", command_writing_marker("default"))],
637        )?;
638
639        let error =
640            run_playground(&config, "missing", None, false).expect_err("unknown playground");
641
642        assert!(error.to_string().contains("unknown playground 'missing'"));
643        Ok(())
644    }
645
646    #[test]
647    fn errors_for_unknown_agent() -> Result<()> {
648        let source_dir = tempdir()?;
649        let save_root = tempdir()?;
650        let config = make_config(
651            source_dir.path(),
652            save_root.path(),
653            "demo",
654            Some("claude"),
655            None,
656            None,
657            &[("claude", command_writing_marker("default"))],
658        )?;
659
660        let error =
661            run_playground(&config, "demo", Some("missing"), false).expect_err("unknown agent");
662
663        assert!(error.to_string().contains("unknown agent 'missing'"));
664        Ok(())
665    }
666
667    #[test]
668    fn uses_default_agent_and_saves_snapshot_when_enabled() -> Result<()> {
669        let source_dir = tempdir()?;
670        let save_root = tempdir()?;
671        let config = make_config(
672            source_dir.path(),
673            save_root.path(),
674            "demo",
675            Some("claude"),
676            None,
677            None,
678            &[("claude", command_writing_marker("default"))],
679        )?;
680
681        let exit_code = run_playground(&config, "demo", None, true)?;
682        let snapshot = single_saved_snapshot(save_root.path())?;
683
684        assert_eq!(exit_code, 0);
685        assert_eq!(
686            fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
687            "default"
688        );
689        assert_eq!(fs::read_to_string(snapshot.join("notes.txt"))?, "hello");
690        assert!(!snapshot.join("apg.toml").exists());
691        Ok(())
692    }
693
694    #[test]
695    fn runs_empty_default_playground_with_default_agent() -> Result<()> {
696        let source_dir = tempdir()?;
697        let save_root = tempdir()?;
698        let config = make_config(
699            source_dir.path(),
700            save_root.path(),
701            "demo",
702            Some("claude"),
703            None,
704            None,
705            &[("claude", command_writing_marker("default"))],
706        )?;
707
708        let exit_code = run_default_playground(&config, None, true)?;
709        let snapshot = single_saved_snapshot(save_root.path())?;
710
711        assert_eq!(exit_code, 0);
712        assert_eq!(
713            fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
714            "default"
715        );
716        assert!(!snapshot.join("notes.txt").exists());
717        assert!(
718            snapshot
719                .file_name()
720                .is_some_and(|name| name.to_string_lossy().starts_with("__default__-"))
721        );
722        Ok(())
723    }
724
725    #[test]
726    fn selected_agent_overrides_default_in_empty_default_playground() -> Result<()> {
727        let source_dir = tempdir()?;
728        let save_root = tempdir()?;
729        let config = make_config(
730            source_dir.path(),
731            save_root.path(),
732            "demo",
733            Some("claude"),
734            None,
735            None,
736            &[
737                ("claude", command_writing_marker("default")),
738                ("codex", command_writing_marker("selected")),
739            ],
740        )?;
741
742        let exit_code = run_default_playground(&config, Some("codex"), true)?;
743        let snapshot = single_saved_snapshot(save_root.path())?;
744
745        assert_eq!(exit_code, 0);
746        assert_eq!(
747            fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
748            "selected"
749        );
750        Ok(())
751    }
752
753    #[test]
754    fn uses_playground_default_agent_before_root_default() -> Result<()> {
755        let source_dir = tempdir()?;
756        let save_root = tempdir()?;
757        let config = make_config(
758            source_dir.path(),
759            save_root.path(),
760            "demo",
761            Some("claude"),
762            Some("codex"),
763            None,
764            &[
765                ("claude", command_writing_marker("root-default")),
766                ("codex", command_writing_marker("playground-default")),
767            ],
768        )?;
769
770        let exit_code = run_playground(&config, "demo", None, true)?;
771        let snapshot = single_saved_snapshot(save_root.path())?;
772
773        assert_eq!(exit_code, 0);
774        assert_eq!(
775            fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
776            "playground-default"
777        );
778        Ok(())
779    }
780
781    #[test]
782    fn selected_agent_overrides_default_agent() -> Result<()> {
783        let source_dir = tempdir()?;
784        let save_root = tempdir()?;
785        let config = make_config(
786            source_dir.path(),
787            save_root.path(),
788            "demo",
789            Some("claude"),
790            Some("opencode"),
791            None,
792            &[
793                ("claude", command_writing_marker("default")),
794                ("opencode", command_writing_marker("playground-default")),
795                ("codex", command_writing_marker("selected")),
796            ],
797        )?;
798
799        let exit_code = run_playground(&config, "demo", Some("codex"), true)?;
800        let snapshot = single_saved_snapshot(save_root.path())?;
801
802        assert_eq!(exit_code, 0);
803        assert_eq!(
804            fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
805            "selected"
806        );
807        Ok(())
808    }
809
810    #[test]
811    fn does_not_save_snapshot_when_disabled() -> Result<()> {
812        let source_dir = tempdir()?;
813        let save_root = tempdir()?;
814        let config = make_config(
815            source_dir.path(),
816            save_root.path(),
817            "demo",
818            Some("claude"),
819            None,
820            None,
821            &[("claude", command_writing_marker("default"))],
822        )?;
823
824        let exit_code = run_playground(&config, "demo", None, false)?;
825
826        assert_eq!(exit_code, 0);
827        assert_eq!(fs::read_dir(save_root.path())?.count(), 0);
828        Ok(())
829    }
830
831    #[test]
832    fn does_not_save_snapshot_when_agent_exits_with_error() -> Result<()> {
833        let source_dir = tempdir()?;
834        let save_root = tempdir()?;
835        let config = make_config(
836            source_dir.path(),
837            save_root.path(),
838            "demo",
839            Some("claude"),
840            None,
841            None,
842            &[("claude", failing_command())],
843        )?;
844
845        let exit_code = run_playground(&config, "demo", None, true)?;
846
847        assert_eq!(exit_code, 7);
848        assert_eq!(fs::read_dir(save_root.path())?.count(), 0);
849        Ok(())
850    }
851
852    #[test]
853    fn loads_dotenv_into_agent_environment_without_copying_file() -> Result<()> {
854        let source_dir = tempdir()?;
855        let save_root = tempdir()?;
856        fs::write(
857            source_dir.path().join(".env"),
858            "PLAYGROUND_SECRET=token-123\n",
859        )?;
860        let config = make_config(
861            source_dir.path(),
862            save_root.path(),
863            "demo",
864            Some("claude"),
865            None,
866            Some(true),
867            &[("claude", command_recording_env("PLAYGROUND_SECRET"))],
868        )?;
869
870        let exit_code = run_playground(&config, "demo", None, true)?;
871        let snapshot = single_saved_snapshot(save_root.path())?;
872        assert_eq!(exit_code, 0);
873        assert_eq!(fs::read_to_string(snapshot.join("env.txt"))?, "token-123");
874        assert!(!snapshot.join(".env").exists());
875        Ok(())
876    }
877}