Skip to main content

bamboo_tools/tools/
bash.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, ToolExecutionContext, ToolResult};
3use bamboo_infrastructure::process::{
4    build_command_environment, decode_process_line_lossy, hide_window_for_tokio_command,
5    preferred_bash_shell, render_command_line, trace_windows_command,
6    windows_command_trace_enabled, PreparedCommandEnvironment,
7};
8use serde::Deserialize;
9use serde_json::{json, Map, Value};
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use tokio::io::{AsyncBufReadExt, BufReader};
13use tokio::process::Command;
14use tokio::time::{Duration, Instant};
15
16use super::{bash_runtime, workspace_state};
17
18const DEFAULT_TIMEOUT_MS: u64 = 120_000;
19const MAX_TIMEOUT_MS: u64 = 600_000;
20const MAX_CAPTURE_BYTES: usize = 512 * 1024;
21
22/// Auto-sync promotion threshold (issue #84, phase 2d). Commands started via the
23/// auto path (`run_in_background` omitted) that are still running after this many
24/// milliseconds are promoted to background instead of continuing to block.
25/// Deliberately generous so virtually all interactive commands stay synchronous.
26const PROMOTE_TO_BACKGROUND_AFTER_MS: u64 = 10_000;
27
28#[derive(Debug, Deserialize)]
29struct BashArgs {
30    command: String,
31    #[serde(default)]
32    timeout: Option<u64>,
33    #[serde(default)]
34    description: Option<String>,
35    #[serde(default)]
36    run_in_background: Option<bool>,
37    #[serde(default)]
38    interactive: Option<bool>,
39    #[serde(default)]
40    workdir: Option<String>,
41}
42
43pub struct BashTool;
44
45impl BashTool {
46    pub fn new() -> Self {
47        Self
48    }
49
50    fn effective_timeout_ms(requested: Option<u64>) -> u64 {
51        let value = requested.unwrap_or(DEFAULT_TIMEOUT_MS);
52        value.clamp(1, MAX_TIMEOUT_MS)
53    }
54
55    fn append_capped(buffer: &mut String, line: &str, truncated: &mut bool) {
56        if *truncated {
57            return;
58        }
59        let needed = line.len() + 1;
60        if buffer.len() + needed <= MAX_CAPTURE_BYTES {
61            buffer.push_str(line);
62            buffer.push('\n');
63            return;
64        }
65
66        let remaining = MAX_CAPTURE_BYTES.saturating_sub(buffer.len());
67        if remaining > 0 {
68            let take = remaining.saturating_sub(1);
69            if take > 0 {
70                let mut end = take.min(line.len());
71                while end > 0 && !line.is_char_boundary(end) {
72                    end -= 1;
73                }
74                buffer.push_str(&line[..end]);
75            }
76            if buffer.len() < MAX_CAPTURE_BYTES {
77                buffer.push('\n');
78            }
79        }
80        *truncated = true;
81    }
82
83    /// Append a line to a promotion-seed buffer, draining the oldest entries
84    /// when over the same budget the background registry enforces
85    /// (`bash_runtime::MAX_OUTPUT_LINES`). Keeps a chatty command from ballooning
86    /// memory during the ~10s promotion window (issue #84, phase 2d) — the
87    /// `stdout_buf`/`stderr_buf` capture is byte-capped, but the seed Vecs feed
88    /// the background buffer and must not grow unbounded. Mirrors `push_line`'s
89    /// drain-oldest semantics.
90    fn push_capped_seed_line(buf: &mut Vec<String>, line: String) {
91        buf.push(line);
92        let cap = bash_runtime::MAX_OUTPUT_LINES;
93        if buf.len() > cap {
94            let overflow = buf.len() - cap;
95            buf.drain(0..overflow);
96        }
97    }
98
99    fn python_diagnostics_json(
100        diagnostics: &bamboo_infrastructure::process::PythonDiscoveryDiagnostics,
101        include_full_tried: bool,
102    ) -> Value {
103        let mut python = Map::new();
104        if let Some(configured) = diagnostics.configured.as_ref() {
105            python.insert("configured".to_string(), json!(configured));
106        }
107        if let Some(resolved) = diagnostics.resolved.as_ref() {
108            python.insert("resolved".to_string(), json!(resolved));
109        }
110        if let Some(invocation) = diagnostics.invocation.as_ref() {
111            python.insert("invocation".to_string(), json!(invocation));
112        }
113        if let Some(source) = diagnostics.source.as_ref() {
114            python.insert("source".to_string(), json!(source));
115        }
116        if !diagnostics.tried_preview.is_empty() {
117            python.insert(
118                "tried_preview".to_string(),
119                json!(diagnostics.tried_preview),
120            );
121        }
122        if diagnostics.tried_total > 0 {
123            python.insert("tried_total".to_string(), json!(diagnostics.tried_total));
124            python.insert(
125                "tried_truncated".to_string(),
126                json!(diagnostics.tried_truncated),
127            );
128        }
129        if let Some(hint) = diagnostics.hint.as_ref() {
130            python.insert("hint".to_string(), json!(hint));
131        }
132        if include_full_tried && !diagnostics.tried.is_empty() {
133            python.insert("tried".to_string(), json!(diagnostics.tried));
134        }
135        Value::Object(python)
136    }
137
138    fn environment_json(
139        diagnostics: &bamboo_infrastructure::process::CommandEnvironmentDiagnostics,
140        include_full_python_tried: bool,
141    ) -> Value {
142        let mut environment = Map::new();
143        environment.insert("source".to_string(), json!(diagnostics.source.as_str()));
144        if let Some(import_shell) = diagnostics.import_shell.as_ref() {
145            environment.insert("import_shell".to_string(), json!(import_shell));
146        }
147        if let Some(import_error) = diagnostics.import_error.as_ref() {
148            environment.insert("import_error".to_string(), json!(import_error));
149        }
150        if let Some(path) = diagnostics.path.as_ref() {
151            environment.insert("path".to_string(), json!(path));
152        }
153        if let Some(path_entries) = diagnostics.path_entries {
154            environment.insert("path_entries".to_string(), json!(path_entries));
155        }
156
157        let python = Self::python_diagnostics_json(&diagnostics.python, include_full_python_tried);
158        if python
159            .as_object()
160            .map(|map| !map.is_empty())
161            .unwrap_or(false)
162        {
163            environment.insert("python".to_string(), python);
164        }
165
166        Value::Object(environment)
167    }
168
169    fn resolve_cwd(session_workspace: &Path, workdir: Option<&str>) -> Result<PathBuf, ToolError> {
170        let resolved = match workdir {
171            Some(raw) => {
172                let trimmed = raw.trim();
173                if trimmed.is_empty() {
174                    return Err(ToolError::InvalidArguments(
175                        "'workdir' cannot be empty".to_string(),
176                    ));
177                }
178                let requested = Path::new(trimmed);
179                if requested.is_absolute() {
180                    requested.to_path_buf()
181                } else {
182                    session_workspace.join(requested)
183                }
184            }
185            None => session_workspace.to_path_buf(),
186        };
187
188        let metadata = std::fs::metadata(&resolved).map_err(|error| {
189            ToolError::InvalidArguments(format!(
190                "Invalid workdir '{}': {}",
191                bamboo_config::paths::path_to_display_string(&resolved),
192                error
193            ))
194        })?;
195        if !metadata.is_dir() {
196            return Err(ToolError::InvalidArguments(format!(
197                "workdir must be a directory: {}",
198                bamboo_config::paths::path_to_display_string(&resolved)
199            )));
200        }
201
202        resolved.canonicalize().map_err(|error| {
203            ToolError::Execution(format!(
204                "Failed to canonicalize workdir '{}': {}",
205                bamboo_config::paths::path_to_display_string(&resolved),
206                error
207            ))
208        })
209    }
210
211    async fn prepare_environment() -> PreparedCommandEnvironment {
212        let overrides = bamboo_llm::Config::current_env_vars();
213        build_command_environment(&overrides).await
214    }
215
216    /// Run a command with streaming output (issue #84, phase 2d).
217    ///
218    /// When `promote_after_ms` is `None`, this is pure synchronous foreground:
219    /// blocks until the command completes or the timeout fires — byte-identical
220    /// to the pre-2d `run_foreground` (same streaming, capture, exit code, result).
221    ///
222    /// When `promote_after_ms` is `Some(ms)`, the command starts foreground but
223    /// is auto-promoted to background if still running after `ms` milliseconds.
224    /// Fast commands that finish before the promotion deadline never reach the
225    /// promotion branch, so their behavior is unchanged. Only the promotion
226    /// deadline (not the timeout) triggers the hand-off: a timeout with
227    /// `ms >= timeout_ms` kills the child exactly as before.
228    async fn run_streaming_command(
229        &self,
230        command: &str,
231        timeout_ms: u64,
232        promote_after_ms: Option<u64>,
233        cwd: &Path,
234        ctx: ToolExecutionContext<'_>,
235    ) -> Result<ToolResult, ToolError> {
236        let shell = preferred_bash_shell();
237        trace_windows_command(
238            "agent.bash.foreground",
239            &shell.program,
240            [shell.arg, command],
241        );
242        if windows_command_trace_enabled() {
243            let rendered = render_command_line(&shell.program, [shell.arg, command]);
244            ctx.emit_tool_token(format!("[windows-cmd-trace] {rendered}\n"))
245                .await;
246        }
247
248        let prepared_env = Self::prepare_environment().await;
249
250        let mut cmd = Command::new(&shell.program);
251        hide_window_for_tokio_command(&mut cmd);
252        cmd.current_dir(cwd);
253        prepared_env.apply_to_tokio_command(&mut cmd);
254        cmd.arg(shell.arg)
255            .arg(command)
256            .stdin(Stdio::null())
257            .stdout(Stdio::piped())
258            .stderr(Stdio::piped())
259            .kill_on_drop(true);
260
261        let mut child = cmd
262            .spawn()
263            .map_err(|e| ToolError::Execution(format!("Failed to execute command: {}", e)))?;
264
265        let stdout = child
266            .stdout
267            .take()
268            .ok_or_else(|| ToolError::Execution("Failed to capture stdout".to_string()))?;
269        let stderr = child
270            .stderr
271            .take()
272            .ok_or_else(|| ToolError::Execution("Failed to capture stderr".to_string()))?;
273
274        let mut stdout_reader = BufReader::new(stdout);
275        let mut stderr_reader = BufReader::new(stderr);
276        let mut stdout_line_bytes = Vec::new();
277        let mut stderr_line_bytes = Vec::new();
278
279        let mut stdout_buf = String::new();
280        let mut stderr_buf = String::new();
281        // Individual decoded lines collected for promotion seeding. Only
282        // populated when promotion is enabled — a cheap Vec::push per line that
283        // is never touched on the pure-foreground path.
284        let mut stdout_lines: Vec<String> = Vec::new();
285        let mut stderr_lines: Vec<String> = Vec::new();
286        let mut stdout_truncated = false;
287        let mut stderr_truncated = false;
288        let mut stdout_done = false;
289        let mut stderr_done = false;
290
291        // Effective deadline: when promotion is enabled, the earlier of the
292        // promotion threshold and the timeout; otherwise just the timeout.
293        let timeout_deadline = Instant::now() + Duration::from_millis(timeout_ms);
294        let effective_deadline = match promote_after_ms {
295            Some(promote_ms) => {
296                (Instant::now() + Duration::from_millis(promote_ms)).min(timeout_deadline)
297            }
298            None => timeout_deadline,
299        };
300
301        while !(stdout_done && stderr_done) {
302            if Instant::now() >= effective_deadline {
303                break;
304            }
305
306            let remaining = effective_deadline.saturating_duration_since(Instant::now());
307            tokio::select! {
308                line = stdout_reader.read_until(b'\n', &mut stdout_line_bytes), if !stdout_done => {
309                    match line {
310                        Ok(0) => stdout_done = true,
311                        Ok(_) => {
312                            let line = decode_process_line_lossy(&mut stdout_line_bytes);
313                            Self::append_capped(&mut stdout_buf, &line, &mut stdout_truncated);
314                            if promote_after_ms.is_some() {
315                                Self::push_capped_seed_line(&mut stdout_lines, line.clone());
316                            }
317                            ctx.emit_tool_token(format!("{}\n", line)).await;
318                        }
319                        Err(e) => {
320                            return Err(ToolError::Execution(format!("Failed reading stdout: {}", e)));
321                        }
322                    }
323                }
324                line = stderr_reader.read_until(b'\n', &mut stderr_line_bytes), if !stderr_done => {
325                    match line {
326                        Ok(0) => stderr_done = true,
327                        Ok(_) => {
328                            let line = decode_process_line_lossy(&mut stderr_line_bytes);
329                            Self::append_capped(&mut stderr_buf, &line, &mut stderr_truncated);
330                            if promote_after_ms.is_some() {
331                                Self::push_capped_seed_line(&mut stderr_lines, line.clone());
332                            }
333                            ctx.emit_tool_token(format!("{}\n", line)).await;
334                        }
335                        Err(e) => {
336                            return Err(ToolError::Execution(format!("Failed reading stderr: {}", e)));
337                        }
338                    }
339                }
340                _ = tokio::time::sleep(remaining) => {
341                    break;
342                }
343            }
344        }
345
346        let streams_closed = stdout_done && stderr_done;
347
348        // A promotion fires only when promotion is enabled AND the promotion
349        // threshold is strictly less than the timeout. When promotion is
350        // disabled or timeout <= promote, the deadline firing is a timeout.
351        let promotion_fired =
352            !streams_closed && promote_after_ms.is_some() && promote_after_ms.unwrap() < timeout_ms;
353
354        if streams_closed {
355            // Command completed normally — identical to the pre-2d foreground path.
356            let status = child
357                .wait()
358                .await
359                .map_err(|e| ToolError::Execution(format!("Failed waiting command: {}", e)))?;
360            let exit_code = status.code();
361            let success = exit_code.unwrap_or(-1) == 0;
362            let cwd_display = bamboo_config::paths::path_to_display_string(cwd);
363            let environment = Self::environment_json(&prepared_env.diagnostics, !success);
364
365            return Ok(ToolResult {
366                success,
367                result: json!({
368                    "command": command,
369                    "cwd": cwd_display,
370                    "stdout": stdout_buf,
371                    "stderr": stderr_buf,
372                    "exit_code": exit_code,
373                    "timed_out": false,
374                    "stdout_truncated": stdout_truncated,
375                    "stderr_truncated": stderr_truncated,
376                    "environment": environment,
377                })
378                .to_string(),
379                display_preference: Some("Collapsible".to_string()),
380                images: Vec::new(),
381            });
382        }
383
384        if promotion_fired {
385            // Promotion deadline fired while the child is still running —
386            // hand off the live child to the background registry. Do NOT kill:
387            // pump tasks continue draining and the completion poll emits
388            // BashCompleted when the child exits (issue #84, phase 2d).
389            //
390            // Flush any partial line bytes left by a cancelled `read_until` —
391            // they represent real bytes consumed from the pipe that must not be
392            // lost across the hand-off.
393            if !stdout_line_bytes.is_empty() {
394                let partial = decode_process_line_lossy(&mut stdout_line_bytes);
395                if !partial.is_empty() {
396                    Self::push_capped_seed_line(&mut stdout_lines, partial);
397                }
398            }
399            if !stderr_line_bytes.is_empty() {
400                let partial = decode_process_line_lossy(&mut stderr_line_bytes);
401                if !partial.is_empty() {
402                    Self::push_capped_seed_line(&mut stderr_lines, partial);
403                }
404            }
405
406            let session = bash_runtime::adopt_running_child(
407                child,
408                stdout_reader,
409                stderr_reader,
410                stdout_lines,
411                stderr_lines,
412                command,
413                ctx.session_id.map(str::to_string),
414                prepared_env.diagnostics.clone(),
415                ctx.cloned_sender(),
416            )
417            .await
418            .map_err(ToolError::Execution)?;
419
420            return Ok(ToolResult {
421                success: true,
422                result: json!({
423                    "bash_id": session.id,
424                    "command": session.command,
425                    "status": "running",
426                    "cwd": bamboo_config::paths::path_to_display_string(cwd),
427                    "environment": Self::environment_json(&session.environment, false),
428                })
429                .to_string(),
430                display_preference: Some("Collapsible".to_string()),
431                images: Vec::new(),
432            });
433        }
434
435        // Timeout fired (promotion disabled or timeout <= promote threshold).
436        let _ = child.kill().await;
437        let cwd_display = bamboo_config::paths::path_to_display_string(cwd);
438        let environment = Self::environment_json(&prepared_env.diagnostics, true);
439
440        Ok(ToolResult {
441            success: false,
442            result: json!({
443                "command": command,
444                "cwd": cwd_display,
445                "stdout": stdout_buf,
446                "stderr": stderr_buf,
447                "exit_code": serde_json::Value::Null,
448                "timed_out": true,
449                "stdout_truncated": stdout_truncated,
450                "stderr_truncated": stderr_truncated,
451                "environment": environment,
452            })
453            .to_string(),
454            display_preference: Some("Collapsible".to_string()),
455            images: Vec::new(),
456        })
457    }
458}
459
460impl Default for BashTool {
461    fn default() -> Self {
462        Self::new()
463    }
464}
465
466#[async_trait]
467impl Tool for BashTool {
468    fn name(&self) -> &str {
469        "Bash"
470    }
471
472    fn description(&self) -> &str {
473        "Execute shell commands with streaming output (supports background mode). \
474         By default (run_in_background omitted), commands run synchronously but are \
475         auto-promoted to background if they run longer than ~10s — fast commands \
476         behave exactly as foreground. Set run_in_background to false to force \
477         synchronous (block until timeout), or true to force immediate background. \
478         Set interactive to true to spawn in the background with a piped stdin so \
479         input can be fed over time via BashInput (interactive implies background; \
480         use it only to answer an interactive prompt). Backgrounded commands are \
481         observed via BashOutput and the loop waits for them at turn end. Default \
482         timeout is 120000ms (max 600000ms); captured stdout/stderr are each \
483         capped at 512KB."
484    }
485
486    fn parameters_schema(&self) -> serde_json::Value {
487        json!({
488            "type": "object",
489            "properties": {
490                "command": {
491                    "type": "string",
492                    "description": "The command to execute"
493                },
494                "timeout": {
495                    "type": "number",
496                    "description": "Optional timeout in milliseconds (default 120000, max 600000)"
497                },
498                "description": {
499                    "type": "string",
500                    "description": "Optional short context label for the command"
501                },
502                "run_in_background": {
503                    "type": "boolean",
504                    "description": "Controls execution mode. Omit (default) for auto: runs synchronously but auto-backgrounds if the command runs longer than ~10s. Set to false to force synchronous (block until timeout). Set to true to force immediate background (observe via BashOutput; the loop waits at turn end)."
505                },
506                "interactive": {
507                    "type": "boolean",
508                    "description": "Opt-in: spawn the command in the BACKGROUND with a piped stdin so input can be fed over time via BashInput (interactive:true implies run_in_background — returns a bash_id immediately). When omitted/false the command's stdin is closed (Stdio::null), so a command that reads stdin gets immediate EOF — the default behavior is unchanged. Use this only to answer an interactive prompt in a long-running background shell."
509                },
510                "workdir": {
511                    "type": "string",
512                    "description": "Optional working directory. Relative paths are resolved from the session workspace."
513                }
514            },
515            "required": ["command"],
516            "additionalProperties": false
517        })
518    }
519
520    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
521        self.execute_with_context(args, ToolExecutionContext::none("Bash"))
522            .await
523    }
524
525    async fn execute_with_context(
526        &self,
527        args: serde_json::Value,
528        ctx: ToolExecutionContext<'_>,
529    ) -> Result<ToolResult, ToolError> {
530        let parsed: BashArgs = serde_json::from_value(args)
531            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Bash args: {}", e)))?;
532
533        let command = parsed.command.trim();
534        if command.is_empty() {
535            return Err(ToolError::InvalidArguments(
536                "'command' cannot be empty".to_string(),
537            ));
538        }
539
540        let _ = parsed.description;
541        let timeout_ms = Self::effective_timeout_ms(parsed.timeout);
542        let session_workspace = workspace_state::workspace_or_process_cwd(ctx.session_id);
543        let cwd = Self::resolve_cwd(&session_workspace, parsed.workdir.as_deref())?;
544
545        if parsed.interactive == Some(true) {
546            // Interactive (issue #89): spawn in the background with a piped
547            // stdin so the shell can be fed input over time via BashInput.
548            // interactive:true implies run_in_background — return a bash_id
549            // immediately. The non-interactive paths below keep Stdio::null(),
550            // so default EOF-on-read is byte-for-byte unchanged.
551            let shell = bash_runtime::spawn_background(
552                command,
553                Some(&cwd),
554                ctx.cloned_sender(),
555                ctx.session_id.map(str::to_string),
556                true,
557            )
558            .await
559            .map_err(ToolError::Execution)?;
560
561            if let Some(requested_timeout) = parsed.timeout {
562                let kill_after_ms = Self::effective_timeout_ms(Some(requested_timeout));
563                let shell_clone = shell.clone();
564                tokio::spawn(async move {
565                    tokio::time::sleep(Duration::from_millis(kill_after_ms)).await;
566                    if shell_clone.status() == "running" {
567                        let _ = shell_clone.kill().await;
568                    }
569                });
570            }
571
572            return Ok(ToolResult {
573                success: true,
574                result: json!({
575                    "bash_id": shell.id,
576                    "command": shell.command,
577                    "status": "running",
578                    "interactive": true,
579                    "cwd": bamboo_config::paths::path_to_display_string(&cwd),
580                    "environment": Self::environment_json(&shell.environment, false),
581                })
582                .to_string(),
583                display_preference: Some("Collapsible".to_string()),
584                images: Vec::new(),
585            });
586        }
587
588        match parsed.run_in_background {
589            Some(true) => {
590                // Force background — spawn immediately (issue #84, phase 1).
591                let shell = bash_runtime::spawn_background(
592                    command,
593                    Some(&cwd),
594                    ctx.cloned_sender(),
595                    ctx.session_id.map(str::to_string),
596                    false,
597                )
598                .await
599                .map_err(ToolError::Execution)?;
600
601                if let Some(requested_timeout) = parsed.timeout {
602                    let kill_after_ms = Self::effective_timeout_ms(Some(requested_timeout));
603                    let shell_clone = shell.clone();
604                    tokio::spawn(async move {
605                        tokio::time::sleep(Duration::from_millis(kill_after_ms)).await;
606                        if shell_clone.status() == "running" {
607                            let _ = shell_clone.kill().await;
608                        }
609                    });
610                }
611
612                Ok(ToolResult {
613                    success: true,
614                    result: json!({
615                        "bash_id": shell.id,
616                        "command": shell.command,
617                        "status": "running",
618                        "cwd": bamboo_config::paths::path_to_display_string(&cwd),
619                        "environment": Self::environment_json(&shell.environment, false),
620                    })
621                    .to_string(),
622                    display_preference: Some("Collapsible".to_string()),
623                    images: Vec::new(),
624                })
625            }
626            Some(false) => {
627                // Force synchronous — pure foreground, no promotion (issue #84,
628                // phase 2d). Blocks until the command completes or times out,
629                // exactly like the pre-2d behavior.
630                self.run_streaming_command(command, timeout_ms, None, &cwd, ctx)
631                    .await
632            }
633            None => {
634                // Auto-sync promotion (issue #84, phase 2d). Runs foreground but
635                // promotes to background if still running after the promotion
636                // threshold (~10s). Fast commands finish synchronously. Promotion
637                // is ONLY enabled when the executing loop can actually suspend
638                // for and self-resume a backgrounded shell (`can_async_resume`):
639                // on hook-less paths (schedule / external-child loops) it stays
640                // purely synchronous so a long command's output is never orphaned.
641                let promote_after_ms = if ctx.can_async_resume {
642                    Some(PROMOTE_TO_BACKGROUND_AFTER_MS)
643                } else {
644                    None
645                };
646                self.run_streaming_command(command, timeout_ms, promote_after_ms, &cwd, ctx)
647                    .await
648            }
649        }
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use bamboo_agent_core::tools::ToolExecutionSessionFlags;
657    use bamboo_agent_core::AgentEvent;
658    use bamboo_infrastructure::process::{
659        clear_command_environment_cache_for_tests, prime_command_environment_cache_for_tests,
660        CommandEnvironmentDiagnostics, CommandEnvironmentSource, PythonDiscoveryDiagnostics,
661    };
662    use serde_json::Value;
663    use std::collections::HashMap;
664    use tokio::sync::mpsc;
665    use tokio::time::{sleep, Duration, Instant};
666
667    #[cfg(target_os = "windows")]
668    fn mixed_output_command() -> &'static str {
669        "echo out && echo err 1>&2"
670    }
671
672    #[cfg(not(target_os = "windows"))]
673    fn mixed_output_command() -> &'static str {
674        "printf 'out\\n'; printf 'err\\n' 1>&2"
675    }
676
677    #[cfg(target_os = "windows")]
678    fn invalid_utf8_stderr_command() -> String {
679        let shell = bamboo_infrastructure::process::preferred_bash_shell();
680        if shell.arg == "-lc" {
681            "printf '\\377\\n' 1>&2".to_string()
682        } else {
683            "powershell -NoProfile -Command \"$bytes = [byte[]](0xFF,0x0A); [Console]::OpenStandardError().Write($bytes,0,$bytes.Length)\"".to_string()
684        }
685    }
686
687    #[cfg(not(target_os = "windows"))]
688    fn invalid_utf8_stderr_command() -> String {
689        "printf '\\377\\n' 1>&2".to_string()
690    }
691
692    fn test_environment_diagnostics() -> CommandEnvironmentDiagnostics {
693        CommandEnvironmentDiagnostics {
694            source: CommandEnvironmentSource::InheritedProcess,
695            import_shell: None,
696            import_error: Some("test-import-disabled".to_string()),
697            path: Some("/usr/bin:/bin".to_string()),
698            path_entries: Some(2),
699            python: PythonDiscoveryDiagnostics {
700                configured: Some("python3".to_string()),
701                resolved: Some("/usr/bin/python3".to_string()),
702                invocation: Some("/usr/bin/python3".to_string()),
703                source: Some("path".to_string()),
704                tried: vec!["python3".to_string(), "python".to_string()],
705                tried_preview: vec!["python3".to_string(), "python".to_string()],
706                tried_total: 2,
707                tried_truncated: false,
708                hint: None,
709            },
710        }
711    }
712
713    fn prime_test_command_environment() {
714        clear_command_environment_cache_for_tests();
715        prime_command_environment_cache_for_tests(
716            HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]),
717            test_environment_diagnostics(),
718        );
719    }
720
721    #[tokio::test]
722    async fn bash_foreground_returns_stdout_stderr_and_streams_tokens() {
723        prime_test_command_environment();
724        let tool = BashTool::new();
725        let (tx, mut rx) = mpsc::channel(32);
726
727        let result = tool
728            .execute_with_context(
729                json!({
730                    "command": mixed_output_command()
731                }),
732                ToolExecutionContext {
733                    session_id: Some("session_1"),
734                    tool_call_id: "call_1",
735                    event_tx: Some(&tx),
736                    available_tool_schemas: None,
737                    bypass_permissions: false,
738                    can_async_resume: false,
739                },
740            )
741            .await
742            .unwrap();
743
744        assert!(result.success);
745
746        let payload: Value = serde_json::from_str(&result.result).unwrap();
747        assert_eq!(payload["timed_out"], false);
748        assert_eq!(payload["exit_code"], 0);
749        assert!(payload["stdout"]
750            .as_str()
751            .unwrap_or_default()
752            .contains("out"));
753        assert!(payload["stderr"]
754            .as_str()
755            .unwrap_or_default()
756            .contains("err"));
757        assert_eq!(payload["environment"]["source"], "process_env");
758        assert_eq!(
759            payload["environment"]["import_error"],
760            "test-import-disabled"
761        );
762        assert_eq!(
763            payload["environment"]["python"]["resolved"],
764            "/usr/bin/python3"
765        );
766        assert_eq!(
767            payload["environment"]["python"]["invocation"],
768            "/usr/bin/python3"
769        );
770        assert_eq!(payload["environment"]["python"]["source"], "path");
771        assert_eq!(
772            payload["environment"]["python"]["tried_preview"][0],
773            "python3"
774        );
775        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
776        assert!(payload["environment"]["python"].get("tried").is_none());
777
778        let mut streamed = Vec::new();
779        while let Ok(event) = rx.try_recv() {
780            if let AgentEvent::ToolToken { content, .. } = event {
781                streamed.push(content);
782            }
783        }
784
785        assert!(streamed.iter().any(|line| line.contains("out")));
786        assert!(streamed.iter().any(|line| line.contains("err")));
787    }
788
789    #[tokio::test]
790    async fn bash_foreground_tolerates_invalid_utf8_stderr() {
791        prime_test_command_environment();
792        let tool = BashTool::new();
793        let result = tool
794            .execute(json!({
795                "command": invalid_utf8_stderr_command()
796            }))
797            .await;
798
799        assert!(result.is_ok(), "invalid UTF-8 stderr should not fail");
800        let payload: Value = serde_json::from_str(&result.unwrap().result).unwrap();
801        let stderr = payload["stderr"].as_str().unwrap_or_default();
802        assert!(!stderr.is_empty());
803    }
804
805    #[cfg(not(target_os = "windows"))]
806    #[tokio::test]
807    async fn bash_foreground_failure_includes_full_python_tried_list() {
808        prime_test_command_environment();
809        let tool = BashTool::new();
810        let result = tool
811            .execute(json!({
812                "command": "false"
813            }))
814            .await
815            .unwrap();
816
817        assert!(!result.success);
818        let payload: Value = serde_json::from_str(&result.result).unwrap();
819        assert_eq!(payload["exit_code"], 1);
820        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
821        assert_eq!(payload["environment"]["python"]["tried"][0], "python3");
822    }
823
824    #[cfg(not(target_os = "windows"))]
825    #[tokio::test]
826    async fn bash_foreground_sets_stdout_truncated_when_output_exceeds_cap() {
827        prime_test_command_environment();
828        let tool = BashTool::new();
829        let result = tool
830            .execute(json!({
831                "command": "i=0; while [ $i -lt 70000 ]; do printf 'aaaaaaaaaa'; i=$((i+1)); done; printf '\\n'"
832            }))
833            .await
834            .unwrap();
835
836        let payload: Value = serde_json::from_str(&result.result).unwrap();
837        assert_eq!(payload["timed_out"], false);
838        assert_eq!(payload["stdout_truncated"], true);
839    }
840
841    #[cfg(not(target_os = "windows"))]
842    #[tokio::test]
843    async fn bash_background_honors_explicit_timeout() {
844        prime_test_command_environment();
845        let tool = BashTool::new();
846        let result = tool
847            .execute(json!({
848                "command": "sleep 2",
849                "run_in_background": true,
850                "timeout": 50
851            }))
852            .await
853            .unwrap();
854        let payload: Value = serde_json::from_str(&result.result).unwrap();
855        assert_eq!(payload["environment"]["source"], "process_env");
856        assert_eq!(
857            payload["environment"]["python"]["resolved"],
858            "/usr/bin/python3"
859        );
860        assert_eq!(
861            payload["environment"]["python"]["invocation"],
862            "/usr/bin/python3"
863        );
864        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
865        assert!(payload["environment"]["python"].get("tried").is_none());
866        let shell_id = payload["bash_id"].as_str().unwrap().to_string();
867
868        let started = Instant::now();
869        loop {
870            let shell = super::bash_runtime::get_shell(&shell_id).unwrap();
871            if shell.status() == "completed" {
872                break;
873            }
874            if started.elapsed() > Duration::from_secs(2) {
875                panic!("background shell did not stop after timeout");
876            }
877            sleep(Duration::from_millis(25)).await;
878        }
879    }
880
881    /// A short background command must emit a `BashCompleted` signal carrying the
882    /// session's `bash_id` and an exit code of `Some(0)` (issue #84, phase 1).
883    #[cfg(not(target_os = "windows"))]
884    #[tokio::test]
885    async fn bash_background_emits_completion_event_with_exit_code() {
886        prime_test_command_environment();
887        let (tx, mut rx) = mpsc::channel(8);
888        let shell = super::bash_runtime::spawn_background("true", None, Some(tx), None, false)
889            .await
890            .expect("background shell should spawn");
891        let expected_id = shell.id.clone();
892
893        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
894            .await
895            .expect("timed out waiting for BashCompleted event")
896            .expect("event channel closed before BashCompleted");
897
898        match event {
899            AgentEvent::BashCompleted {
900                bash_id,
901                command,
902                exit_code,
903                status,
904            } => {
905                assert_eq!(bash_id, expected_id);
906                assert_eq!(command, "true");
907                assert_eq!(exit_code, Some(0));
908                assert_eq!(status, "completed");
909            }
910            other => panic!("expected BashCompleted, got {other:?}"),
911        }
912    }
913
914    /// A failing background command still reports `status="completed"` with its
915    /// non-zero exit code — a non-zero exit is a normal completion, not a kill.
916    #[cfg(not(target_os = "windows"))]
917    #[tokio::test]
918    async fn bash_background_emits_completion_event_for_failing_command() {
919        prime_test_command_environment();
920        let (tx, mut rx) = mpsc::channel(8);
921        let shell = super::bash_runtime::spawn_background("false", None, Some(tx), None, false)
922            .await
923            .expect("background shell should spawn");
924        let expected_id = shell.id.clone();
925
926        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
927            .await
928            .expect("timed out waiting for BashCompleted event")
929            .expect("event channel closed before BashCompleted");
930
931        match event {
932            AgentEvent::BashCompleted {
933                bash_id,
934                exit_code,
935                status,
936                ..
937            } => {
938                assert_eq!(bash_id, expected_id);
939                assert_eq!(exit_code, Some(1));
940                assert_eq!(status, "completed");
941            }
942            other => panic!("expected BashCompleted, got {other:?}"),
943        }
944    }
945
946    /// A killed background command must report `status="killed"` with
947    /// `exit_code=None` (no numeric code for signal termination on Unix).
948    #[cfg(not(target_os = "windows"))]
949    #[tokio::test]
950    async fn bash_background_emits_killed_when_shell_is_killed() {
951        prime_test_command_environment();
952        let (tx, mut rx) = mpsc::channel(8);
953        let shell = super::bash_runtime::spawn_background("sleep 30", None, Some(tx), None, false)
954            .await
955            .expect("background shell should spawn");
956        let expected_id = shell.id.clone();
957
958        shell.kill().await.expect("shell should be killable");
959
960        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
961            .await
962            .expect("timed out waiting for BashCompleted event")
963            .expect("event channel closed before BashCompleted");
964
965        match event {
966            AgentEvent::BashCompleted {
967                bash_id,
968                exit_code,
969                status,
970                ..
971            } => {
972                assert_eq!(bash_id, expected_id);
973                assert_eq!(exit_code, None);
974                assert_eq!(status, "killed");
975            }
976            other => panic!("expected BashCompleted, got {other:?}"),
977        }
978    }
979
980    /// With no event sender, the poll task must skip the emit entirely and still
981    /// flip the shell to "completed" (issue #84, phase 1).
982    #[cfg(not(target_os = "windows"))]
983    #[tokio::test]
984    async fn bash_background_without_sender_still_completes() {
985        prime_test_command_environment();
986        let shell = super::bash_runtime::spawn_background("true", None, None, None, false)
987            .await
988            .expect("background shell should spawn");
989
990        let started = Instant::now();
991        loop {
992            if shell.status() == "completed" {
993                break;
994            }
995            if started.elapsed() > Duration::from_secs(3) {
996                panic!("shell never reached completed without a sender");
997            }
998            sleep(Duration::from_millis(25)).await;
999        }
1000    }
1001
1002    /// A saturated event channel must not hang or panic the poll task: the
1003    /// completion send hits the 500ms bounded-timeout path, logs a `warn!`, and
1004    /// the shell still completes. The signal is dropped (observable), not lost
1005    /// silently. The channel is kept full past the timeout window so the send is
1006    /// guaranteed to time out rather than succeed when a slot is freed.
1007    #[cfg(not(target_os = "windows"))]
1008    #[tokio::test]
1009    async fn bash_background_drops_completion_when_channel_saturated() {
1010        prime_test_command_environment();
1011        // Capacity-1 channel pre-filled so the single slot is occupied.
1012        let (tx, mut rx) = mpsc::channel::<AgentEvent>(1);
1013        tx.try_send(AgentEvent::Token {
1014            content: "occupy".into(),
1015        })
1016        .expect("prefill channel slot");
1017
1018        let shell = super::bash_runtime::spawn_background("true", None, Some(tx), None, false)
1019            .await
1020            .expect("background shell should spawn");
1021
1022        // Wait past the 500ms bounded-send window so the dropped BashCompleted
1023        // has been observed and the poll task has moved on.
1024        sleep(Duration::from_millis(650)).await;
1025
1026        // The only event ever delivered is the pre-filled token; BashCompleted
1027        // was dropped (saturated channel) and never enqueued.
1028        let only = rx
1029            .recv()
1030            .await
1031            .expect("prefilled token should still be present");
1032        assert!(
1033            matches!(only, AgentEvent::Token { .. }),
1034            "expected only the pre-filled token, got {only:?}"
1035        );
1036        assert!(
1037            tokio::time::timeout(Duration::from_millis(50), rx.recv())
1038                .await
1039                .is_err(),
1040            "no BashCompleted should be delivered after a saturation drop"
1041        );
1042        assert_eq!(
1043            shell.status(),
1044            "completed",
1045            "shell must still reach completed after a dropped signal"
1046        );
1047    }
1048
1049    /// Drives the real tool dispatch path: `BashTool::execute_with_context`
1050    /// with `run_in_background=true` and a context built via `for_dispatch`
1051    /// carrying an `event_tx`, so the signal flows through `ctx.cloned_sender()`
1052    /// (the production wiring), not just `spawn_background` directly.
1053    #[cfg(not(target_os = "windows"))]
1054    #[tokio::test]
1055    async fn bash_tool_background_dispatch_emits_completion_event() {
1056        prime_test_command_environment();
1057        let tool = BashTool::new();
1058        let (tx, mut rx) = mpsc::channel(8);
1059        let ctx = ToolExecutionContext::for_dispatch(
1060            "session_84",
1061            "call_84",
1062            &tx,
1063            &[],
1064            ToolExecutionSessionFlags::default(),
1065            true,
1066        );
1067
1068        let result = tool
1069            .execute_with_context(json!({ "command": "true", "run_in_background": true }), ctx)
1070            .await
1071            .expect("background dispatch should succeed");
1072        assert!(result.success);
1073
1074        let payload: Value = serde_json::from_str(&result.result).unwrap();
1075        let bash_id = payload["bash_id"].as_str().unwrap().to_string();
1076        assert_eq!(payload["status"], "running");
1077
1078        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1079            .await
1080            .expect("timed out waiting for BashCompleted event")
1081            .expect("event channel closed before BashCompleted");
1082
1083        match event {
1084            AgentEvent::BashCompleted {
1085                bash_id: id,
1086                exit_code,
1087                status,
1088                ..
1089            } => {
1090                assert_eq!(id, bash_id);
1091                assert_eq!(exit_code, Some(0));
1092                assert_eq!(status, "completed");
1093            }
1094            other => panic!("expected BashCompleted, got {other:?}"),
1095        }
1096    }
1097
1098    #[tokio::test]
1099    async fn bash_resolves_relative_workdir_from_session_workspace() {
1100        prime_test_command_environment();
1101        let tool = BashTool::new();
1102        let dir = tempfile::tempdir().unwrap();
1103        let base = dir.path().join("base");
1104        let nested = base.join("nested");
1105        tokio::fs::create_dir_all(&nested).await.unwrap();
1106
1107        let session_id = format!("session_{}", uuid::Uuid::new_v4());
1108        super::workspace_state::set_workspace(&session_id, base.canonicalize().unwrap());
1109
1110        let result = tool
1111            .execute_with_context(
1112                json!({
1113                    "command": "pwd",
1114                    "workdir": "nested"
1115                }),
1116                ToolExecutionContext {
1117                    session_id: Some(&session_id),
1118                    tool_call_id: "call_1",
1119                    event_tx: None,
1120                    available_tool_schemas: None,
1121                    bypass_permissions: false,
1122                    can_async_resume: false,
1123                },
1124            )
1125            .await
1126            .unwrap();
1127
1128        let payload: Value = serde_json::from_str(&result.result).unwrap();
1129        let expected =
1130            bamboo_config::paths::path_to_display_string(&nested.canonicalize().unwrap());
1131        assert_eq!(payload["cwd"].as_str().unwrap_or_default(), expected);
1132    }
1133
1134    #[tokio::test]
1135    async fn bash_rejects_workdir_that_is_not_directory() {
1136        prime_test_command_environment();
1137        let tool = BashTool::new();
1138        let file = tempfile::NamedTempFile::new().unwrap();
1139
1140        let result = tool
1141            .execute(json!({
1142                "command": "echo hello",
1143                "workdir": file.path()
1144            }))
1145            .await;
1146
1147        assert!(
1148            matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("directory"))
1149        );
1150    }
1151
1152    /// `running_shells_for_session` reports only the running shells tagged with the
1153    /// requested session, excluding completed shells and shells owned by another
1154    /// session (or none). (issue #84, phase 2a.)
1155    #[cfg(not(target_os = "windows"))]
1156    #[tokio::test]
1157    async fn running_shells_for_session_filters_by_session_and_status() {
1158        prime_test_command_environment();
1159
1160        // Two long-running shells owned by sess-A.
1161        let a1 = super::bash_runtime::spawn_background(
1162            "sleep 30",
1163            None,
1164            None,
1165            Some("sess-A".to_string()),
1166            false,
1167        )
1168        .await
1169        .expect("spawn a1");
1170        let a2 = super::bash_runtime::spawn_background(
1171            "sleep 30",
1172            None,
1173            None,
1174            Some("sess-A".to_string()),
1175            false,
1176        )
1177        .await
1178        .expect("spawn a2");
1179        // One long-running shell owned by sess-B.
1180        let b = super::bash_runtime::spawn_background(
1181            "sleep 30",
1182            None,
1183            None,
1184            Some("sess-B".to_string()),
1185            false,
1186        )
1187        .await
1188        .expect("spawn b");
1189        // An untagged (None) long-running shell.
1190        let untagged = super::bash_runtime::spawn_background("sleep 30", None, None, None, false)
1191            .await
1192            .expect("spawn untagged");
1193        // A sess-A shell that completes immediately — must be excluded once done.
1194        let done = super::bash_runtime::spawn_background(
1195            "true",
1196            None,
1197            None,
1198            Some("sess-A".to_string()),
1199            false,
1200        )
1201        .await
1202        .expect("spawn done");
1203
1204        // Wait for the fast shell to reach "completed" so we exercise the status filter.
1205        let started = Instant::now();
1206        loop {
1207            if done.status() == "completed" {
1208                break;
1209            }
1210            if started.elapsed() > Duration::from_secs(3) {
1211                panic!("sess-A fast shell never completed");
1212            }
1213            sleep(Duration::from_millis(25)).await;
1214        }
1215
1216        // sess-A should report exactly the two long-running shells, order-independent.
1217        let mut running = super::bash_runtime::running_shells_for_session("sess-A");
1218        running.sort();
1219        let mut expected = vec![a1.id.clone(), a2.id.clone()];
1220        expected.sort();
1221        assert_eq!(running, expected);
1222
1223        // sess-B should report exactly its own single running shell.
1224        assert_eq!(
1225            super::bash_runtime::running_shells_for_session("sess-B"),
1226            vec![b.id.clone()]
1227        );
1228
1229        // Cleanup: kill the still-running shells so the GC isn't left holding them,
1230        // and drop the already-completed `done` shell from the process-global registry.
1231        for shell in [a1, a2, b, untagged] {
1232            let _ = shell.kill().await;
1233        }
1234        let _ = super::bash_runtime::remove_shell(&done.id);
1235    }
1236
1237    // ── Auto-sync promotion tests (issue #84, phase 2d) ──────────────────
1238
1239    // (a) A fast command via the auto (None) path returns a synchronous result
1240    // with stdout + exit_code and no bash_id — identical to the old foreground.
1241    #[cfg(not(target_os = "windows"))]
1242    #[tokio::test]
1243    async fn auto_path_fast_command_returns_synchronous_result() {
1244        prime_test_command_environment();
1245        let tool = BashTool::new();
1246        let (tx, _rx) = mpsc::channel(32);
1247        let ctx = ToolExecutionContext {
1248            session_id: Some("session_auto_fast"),
1249            tool_call_id: "call_auto_fast",
1250            event_tx: Some(&tx),
1251            available_tool_schemas: None,
1252            bypass_permissions: false,
1253            can_async_resume: false,
1254        };
1255
1256        let result = tool
1257            .execute_with_context(json!({ "command": "echo auto-fast-output" }), ctx)
1258            .await
1259            .expect("auto fast command should succeed");
1260
1261        let payload: Value = serde_json::from_str(&result.result).unwrap();
1262        assert!(
1263            payload.get("bash_id").is_none(),
1264            "fast auto command must not return a bash_id"
1265        );
1266        assert_eq!(payload["exit_code"], 0);
1267        assert_eq!(payload["timed_out"], false);
1268        assert!(payload["stdout"]
1269            .as_str()
1270            .unwrap_or_default()
1271            .contains("auto-fast-output"));
1272    }
1273
1274    // (b) Promotion: with a low test threshold, a long command (sleep) on the
1275    // auto path returns a background {bash_id, running} result, the adopted
1276    // shell appears in running_shells_for_session, and later emits
1277    // BashCompleted with the right id.
1278    #[cfg(not(target_os = "windows"))]
1279    #[tokio::test]
1280    async fn auto_path_promotes_long_command_to_background() {
1281        prime_test_command_environment();
1282        let tool = BashTool::new();
1283        let session_id = "session_auto_promote";
1284        let (tx, mut rx) = mpsc::channel(8);
1285        let ctx = ToolExecutionContext::for_dispatch(
1286            session_id,
1287            "call_auto_promote",
1288            &tx,
1289            &[],
1290            ToolExecutionSessionFlags::default(),
1291            // `can_async_resume` is irrelevant here — this test drives
1292            // promotion directly via run_streaming_command(Some(200)).
1293            true,
1294        );
1295        let cwd = super::workspace_state::workspace_or_process_cwd(Some(session_id));
1296
1297        // Call run_streaming_command directly with a 200ms promotion threshold
1298        // so this test exercises promotion deterministically without depending
1299        // on the production 10s default or the None dispatch arm's
1300        // `can_async_resume` gating.
1301        let result = tool
1302            .run_streaming_command("sleep 10", 60000, Some(200), &cwd, ctx)
1303            .await
1304            .expect("auto promote should succeed");
1305
1306        assert!(result.success);
1307        let payload: Value = serde_json::from_str(&result.result).unwrap();
1308        let bash_id = payload["bash_id"]
1309            .as_str()
1310            .expect("promoted result must have bash_id")
1311            .to_string();
1312        assert_eq!(payload["status"], "running");
1313
1314        let running = super::bash_runtime::running_shells_for_session(session_id);
1315        assert!(
1316            running.contains(&bash_id),
1317            "adopted shell {bash_id} must appear in running_shells, got {running:?}"
1318        );
1319
1320        let event = tokio::time::timeout(Duration::from_secs(15), rx.recv())
1321            .await
1322            .expect("timed out waiting for BashCompleted")
1323            .expect("event channel closed before BashCompleted");
1324        match event {
1325            AgentEvent::BashCompleted {
1326                bash_id: id,
1327                exit_code,
1328                status,
1329                ..
1330            } => {
1331                assert_eq!(id, bash_id);
1332                assert_eq!(exit_code, Some(0));
1333                assert_eq!(status, "completed");
1334            }
1335            other => panic!("expected BashCompleted, got {other:?}"),
1336        }
1337
1338        if let Some(shell) = super::bash_runtime::get_shell(&bash_id) {
1339            let _ = shell.kill().await;
1340        }
1341    }
1342
1343    // (c) run_in_background:Some(false) does NOT promote — blocks/times out
1344    // like the pre-2d foreground path.
1345    #[cfg(not(target_os = "windows"))]
1346    #[tokio::test]
1347    async fn force_sync_does_not_promote_and_times_out() {
1348        prime_test_command_environment();
1349        let tool = BashTool::new();
1350
1351        let result = tool
1352            .execute(json!({
1353                "command": "sleep 10",
1354                "run_in_background": false,
1355                "timeout": 50
1356            }))
1357            .await
1358            .expect("force-sync should produce a timed-out result");
1359
1360        let payload: Value = serde_json::from_str(&result.result).unwrap();
1361        assert!(
1362            payload.get("bash_id").is_none(),
1363            "force-sync must never promote"
1364        );
1365        assert_eq!(payload["timed_out"], true);
1366        assert!(!result.success, "timed-out result must not be successful");
1367    }
1368
1369    // (e) Auto path (run_in_background OMITTED) with can_async_resume == false
1370    // also does NOT promote — it runs purely synchronously, exactly like (c),
1371    // even though promotion is now the default. This is the hook-less-path guard
1372    // (issue #84, phase 2d): a loop that can't suspend+resume a background shell
1373    // (schedule path, agent-core loop) must never orphan one via auto-promotion.
1374    // `execute()` builds `ToolExecutionContext::none`, whose can_async_resume is
1375    // false, so the None dispatch arm passes promote_after_ms = None.
1376    #[cfg(not(target_os = "windows"))]
1377    #[tokio::test]
1378    async fn auto_path_does_not_promote_when_not_resume_capable() {
1379        prime_test_command_environment();
1380        let tool = BashTool::new();
1381
1382        let result = tool
1383            .execute(json!({
1384                "command": "sleep 10",
1385                "timeout": 50
1386            }))
1387            .await
1388            .expect("non-resume-capable auto path should produce a result");
1389
1390        let payload: Value = serde_json::from_str(&result.result).unwrap();
1391        assert!(
1392            payload.get("bash_id").is_none(),
1393            "auto path must not promote when can_async_resume is false"
1394        );
1395        assert_eq!(payload["timed_out"], true);
1396        assert!(
1397            !result.success,
1398            "a timed-out command must not report success"
1399        );
1400    }
1401
1402    // (d) adopt_running_child preserves already-captured output: seed lines
1403    // appear in subsequent read_output_since calls.
1404    #[cfg(not(target_os = "windows"))]
1405    #[tokio::test]
1406    async fn adopt_running_child_preserves_seeded_output() {
1407        prime_test_command_environment();
1408        let shell = bamboo_infrastructure::process::preferred_bash_shell();
1409        let mut cmd = tokio::process::Command::new(&shell.program);
1410        bamboo_infrastructure::process::hide_window_for_tokio_command(&mut cmd);
1411        cmd.arg(shell.arg)
1412            .arg("echo seeded-line-1; echo seeded-line-2; sleep 5")
1413            .stdin(std::process::Stdio::null())
1414            .stdout(std::process::Stdio::piped())
1415            .stderr(std::process::Stdio::piped())
1416            .kill_on_drop(true);
1417        let mut child = cmd.spawn().expect("spawn child");
1418        let stdout_reader = tokio::io::BufReader::new(child.stdout.take().unwrap());
1419        let stderr_reader = tokio::io::BufReader::new(child.stderr.take().unwrap());
1420
1421        // Let the echo output land in the pipe buffer before adoption.
1422        sleep(Duration::from_millis(200)).await;
1423
1424        let session = super::bash_runtime::adopt_running_child(
1425            child,
1426            stdout_reader,
1427            stderr_reader,
1428            vec!["seeded-line-1".to_string(), "seeded-line-2".to_string()],
1429            vec![],
1430            "echo seeded-line-1; echo seeded-line-2; sleep 5",
1431            Some("session_seed_test".to_string()),
1432            test_environment_diagnostics(),
1433            None,
1434        )
1435        .await
1436        .expect("adopt should succeed");
1437
1438        let (lines, _cursor, _dropped) = session.read_output_since(0, None).await;
1439        assert!(
1440            lines.iter().any(|l| l.contains("seeded-line-1")),
1441            "seeded line 1 must be present, got {lines:?}"
1442        );
1443        assert!(
1444            lines.iter().any(|l| l.contains("seeded-line-2")),
1445            "seeded line 2 must be present, got {lines:?}"
1446        );
1447
1448        let _ = session.kill().await;
1449        let _ = super::bash_runtime::remove_shell(&session.id);
1450    }
1451}