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                    pre_parsed_args: None,
740                },
741            )
742            .await
743            .unwrap();
744
745        assert!(result.success);
746
747        let payload: Value = serde_json::from_str(&result.result).unwrap();
748        assert_eq!(payload["timed_out"], false);
749        assert_eq!(payload["exit_code"], 0);
750        assert!(payload["stdout"]
751            .as_str()
752            .unwrap_or_default()
753            .contains("out"));
754        assert!(payload["stderr"]
755            .as_str()
756            .unwrap_or_default()
757            .contains("err"));
758        assert_eq!(payload["environment"]["source"], "process_env");
759        assert_eq!(
760            payload["environment"]["import_error"],
761            "test-import-disabled"
762        );
763        assert_eq!(
764            payload["environment"]["python"]["resolved"],
765            "/usr/bin/python3"
766        );
767        assert_eq!(
768            payload["environment"]["python"]["invocation"],
769            "/usr/bin/python3"
770        );
771        assert_eq!(payload["environment"]["python"]["source"], "path");
772        assert_eq!(
773            payload["environment"]["python"]["tried_preview"][0],
774            "python3"
775        );
776        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
777        assert!(payload["environment"]["python"].get("tried").is_none());
778
779        let mut streamed = Vec::new();
780        while let Ok(event) = rx.try_recv() {
781            if let AgentEvent::ToolToken { content, .. } = event {
782                streamed.push(content);
783            }
784        }
785
786        assert!(streamed.iter().any(|line| line.contains("out")));
787        assert!(streamed.iter().any(|line| line.contains("err")));
788    }
789
790    #[tokio::test]
791    async fn bash_foreground_tolerates_invalid_utf8_stderr() {
792        prime_test_command_environment();
793        let tool = BashTool::new();
794        let result = tool
795            .execute(json!({
796                "command": invalid_utf8_stderr_command()
797            }))
798            .await;
799
800        assert!(result.is_ok(), "invalid UTF-8 stderr should not fail");
801        let payload: Value = serde_json::from_str(&result.unwrap().result).unwrap();
802        let stderr = payload["stderr"].as_str().unwrap_or_default();
803        assert!(!stderr.is_empty());
804    }
805
806    #[cfg(not(target_os = "windows"))]
807    #[tokio::test]
808    async fn bash_foreground_failure_includes_full_python_tried_list() {
809        prime_test_command_environment();
810        let tool = BashTool::new();
811        let result = tool
812            .execute(json!({
813                "command": "false"
814            }))
815            .await
816            .unwrap();
817
818        assert!(!result.success);
819        let payload: Value = serde_json::from_str(&result.result).unwrap();
820        assert_eq!(payload["exit_code"], 1);
821        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
822        assert_eq!(payload["environment"]["python"]["tried"][0], "python3");
823    }
824
825    #[cfg(not(target_os = "windows"))]
826    #[tokio::test]
827    async fn bash_foreground_sets_stdout_truncated_when_output_exceeds_cap() {
828        prime_test_command_environment();
829        let tool = BashTool::new();
830        let result = tool
831            .execute(json!({
832                "command": "i=0; while [ $i -lt 70000 ]; do printf 'aaaaaaaaaa'; i=$((i+1)); done; printf '\\n'"
833            }))
834            .await
835            .unwrap();
836
837        let payload: Value = serde_json::from_str(&result.result).unwrap();
838        assert_eq!(payload["timed_out"], false);
839        assert_eq!(payload["stdout_truncated"], true);
840    }
841
842    #[cfg(not(target_os = "windows"))]
843    #[tokio::test]
844    async fn bash_background_honors_explicit_timeout() {
845        prime_test_command_environment();
846        let tool = BashTool::new();
847        let result = tool
848            .execute(json!({
849                "command": "sleep 2",
850                "run_in_background": true,
851                "timeout": 50
852            }))
853            .await
854            .unwrap();
855        let payload: Value = serde_json::from_str(&result.result).unwrap();
856        assert_eq!(payload["environment"]["source"], "process_env");
857        assert_eq!(
858            payload["environment"]["python"]["resolved"],
859            "/usr/bin/python3"
860        );
861        assert_eq!(
862            payload["environment"]["python"]["invocation"],
863            "/usr/bin/python3"
864        );
865        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
866        assert!(payload["environment"]["python"].get("tried").is_none());
867        let shell_id = payload["bash_id"].as_str().unwrap().to_string();
868
869        let started = Instant::now();
870        loop {
871            let shell = super::bash_runtime::get_shell(&shell_id).unwrap();
872            if shell.status() == "completed" {
873                break;
874            }
875            if started.elapsed() > Duration::from_secs(2) {
876                panic!("background shell did not stop after timeout");
877            }
878            sleep(Duration::from_millis(25)).await;
879        }
880    }
881
882    /// A short background command must emit a `BashCompleted` signal carrying the
883    /// session's `bash_id` and an exit code of `Some(0)` (issue #84, phase 1).
884    #[cfg(not(target_os = "windows"))]
885    #[tokio::test]
886    async fn bash_background_emits_completion_event_with_exit_code() {
887        prime_test_command_environment();
888        let (tx, mut rx) = mpsc::channel(8);
889        let shell = super::bash_runtime::spawn_background("true", None, Some(tx), None, false)
890            .await
891            .expect("background shell should spawn");
892        let expected_id = shell.id.clone();
893
894        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
895            .await
896            .expect("timed out waiting for BashCompleted event")
897            .expect("event channel closed before BashCompleted");
898
899        match event {
900            AgentEvent::BashCompleted {
901                bash_id,
902                command,
903                exit_code,
904                status,
905            } => {
906                assert_eq!(bash_id, expected_id);
907                assert_eq!(command, "true");
908                assert_eq!(exit_code, Some(0));
909                assert_eq!(status, "completed");
910            }
911            other => panic!("expected BashCompleted, got {other:?}"),
912        }
913    }
914
915    /// A failing background command still reports `status="completed"` with its
916    /// non-zero exit code — a non-zero exit is a normal completion, not a kill.
917    #[cfg(not(target_os = "windows"))]
918    #[tokio::test]
919    async fn bash_background_emits_completion_event_for_failing_command() {
920        prime_test_command_environment();
921        let (tx, mut rx) = mpsc::channel(8);
922        let shell = super::bash_runtime::spawn_background("false", None, Some(tx), None, false)
923            .await
924            .expect("background shell should spawn");
925        let expected_id = shell.id.clone();
926
927        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
928            .await
929            .expect("timed out waiting for BashCompleted event")
930            .expect("event channel closed before BashCompleted");
931
932        match event {
933            AgentEvent::BashCompleted {
934                bash_id,
935                exit_code,
936                status,
937                ..
938            } => {
939                assert_eq!(bash_id, expected_id);
940                assert_eq!(exit_code, Some(1));
941                assert_eq!(status, "completed");
942            }
943            other => panic!("expected BashCompleted, got {other:?}"),
944        }
945    }
946
947    /// A killed background command must report `status="killed"` with
948    /// `exit_code=None` (no numeric code for signal termination on Unix).
949    #[cfg(not(target_os = "windows"))]
950    #[tokio::test]
951    async fn bash_background_emits_killed_when_shell_is_killed() {
952        prime_test_command_environment();
953        let (tx, mut rx) = mpsc::channel(8);
954        let shell = super::bash_runtime::spawn_background("sleep 30", None, Some(tx), None, false)
955            .await
956            .expect("background shell should spawn");
957        let expected_id = shell.id.clone();
958
959        shell.kill().await.expect("shell should be killable");
960
961        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
962            .await
963            .expect("timed out waiting for BashCompleted event")
964            .expect("event channel closed before BashCompleted");
965
966        match event {
967            AgentEvent::BashCompleted {
968                bash_id,
969                exit_code,
970                status,
971                ..
972            } => {
973                assert_eq!(bash_id, expected_id);
974                assert_eq!(exit_code, None);
975                assert_eq!(status, "killed");
976            }
977            other => panic!("expected BashCompleted, got {other:?}"),
978        }
979    }
980
981    /// With no event sender, the poll task must skip the emit entirely and still
982    /// flip the shell to "completed" (issue #84, phase 1).
983    #[cfg(not(target_os = "windows"))]
984    #[tokio::test]
985    async fn bash_background_without_sender_still_completes() {
986        prime_test_command_environment();
987        let shell = super::bash_runtime::spawn_background("true", None, None, None, false)
988            .await
989            .expect("background shell should spawn");
990
991        let started = Instant::now();
992        loop {
993            if shell.status() == "completed" {
994                break;
995            }
996            if started.elapsed() > Duration::from_secs(3) {
997                panic!("shell never reached completed without a sender");
998            }
999            sleep(Duration::from_millis(25)).await;
1000        }
1001    }
1002
1003    /// A saturated event channel must not hang or panic the poll task: the
1004    /// completion send hits the 500ms bounded-timeout path, logs a `warn!`, and
1005    /// the shell still completes. The signal is dropped (observable), not lost
1006    /// silently. The channel is kept full past the timeout window so the send is
1007    /// guaranteed to time out rather than succeed when a slot is freed.
1008    #[cfg(not(target_os = "windows"))]
1009    #[tokio::test]
1010    async fn bash_background_drops_completion_when_channel_saturated() {
1011        prime_test_command_environment();
1012        // Capacity-1 channel pre-filled so the single slot is occupied.
1013        let (tx, mut rx) = mpsc::channel::<AgentEvent>(1);
1014        tx.try_send(AgentEvent::Token {
1015            content: "occupy".into(),
1016        })
1017        .expect("prefill channel slot");
1018
1019        let shell = super::bash_runtime::spawn_background("true", None, Some(tx), None, false)
1020            .await
1021            .expect("background shell should spawn");
1022
1023        // Wait past the 500ms bounded-send window so the dropped BashCompleted
1024        // has been observed and the poll task has moved on.
1025        sleep(Duration::from_millis(650)).await;
1026
1027        // The only event ever delivered is the pre-filled token; BashCompleted
1028        // was dropped (saturated channel) and never enqueued.
1029        let only = rx
1030            .recv()
1031            .await
1032            .expect("prefilled token should still be present");
1033        assert!(
1034            matches!(only, AgentEvent::Token { .. }),
1035            "expected only the pre-filled token, got {only:?}"
1036        );
1037        assert!(
1038            tokio::time::timeout(Duration::from_millis(50), rx.recv())
1039                .await
1040                .is_err(),
1041            "no BashCompleted should be delivered after a saturation drop"
1042        );
1043        assert_eq!(
1044            shell.status(),
1045            "completed",
1046            "shell must still reach completed after a dropped signal"
1047        );
1048    }
1049
1050    /// Drives the real tool dispatch path: `BashTool::execute_with_context`
1051    /// with `run_in_background=true` and a context built via `for_dispatch`
1052    /// carrying an `event_tx`, so the signal flows through `ctx.cloned_sender()`
1053    /// (the production wiring), not just `spawn_background` directly.
1054    #[cfg(not(target_os = "windows"))]
1055    #[tokio::test]
1056    async fn bash_tool_background_dispatch_emits_completion_event() {
1057        prime_test_command_environment();
1058        let tool = BashTool::new();
1059        let (tx, mut rx) = mpsc::channel(8);
1060        let ctx = ToolExecutionContext::for_dispatch(
1061            "session_84",
1062            "call_84",
1063            &tx,
1064            &[],
1065            ToolExecutionSessionFlags::default(),
1066            true,
1067            None,
1068        );
1069
1070        let result = tool
1071            .execute_with_context(json!({ "command": "true", "run_in_background": true }), ctx)
1072            .await
1073            .expect("background dispatch should succeed");
1074        assert!(result.success);
1075
1076        let payload: Value = serde_json::from_str(&result.result).unwrap();
1077        let bash_id = payload["bash_id"].as_str().unwrap().to_string();
1078        assert_eq!(payload["status"], "running");
1079
1080        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1081            .await
1082            .expect("timed out waiting for BashCompleted event")
1083            .expect("event channel closed before BashCompleted");
1084
1085        match event {
1086            AgentEvent::BashCompleted {
1087                bash_id: id,
1088                exit_code,
1089                status,
1090                ..
1091            } => {
1092                assert_eq!(id, bash_id);
1093                assert_eq!(exit_code, Some(0));
1094                assert_eq!(status, "completed");
1095            }
1096            other => panic!("expected BashCompleted, got {other:?}"),
1097        }
1098    }
1099
1100    #[tokio::test]
1101    async fn bash_resolves_relative_workdir_from_session_workspace() {
1102        prime_test_command_environment();
1103        let tool = BashTool::new();
1104        let dir = tempfile::tempdir().unwrap();
1105        let base = dir.path().join("base");
1106        let nested = base.join("nested");
1107        tokio::fs::create_dir_all(&nested).await.unwrap();
1108
1109        let session_id = format!("session_{}", uuid::Uuid::new_v4());
1110        super::workspace_state::set_workspace(&session_id, base.canonicalize().unwrap());
1111
1112        let result = tool
1113            .execute_with_context(
1114                json!({
1115                    "command": "pwd",
1116                    "workdir": "nested"
1117                }),
1118                ToolExecutionContext {
1119                    session_id: Some(&session_id),
1120                    tool_call_id: "call_1",
1121                    event_tx: None,
1122                    available_tool_schemas: None,
1123                    bypass_permissions: false,
1124                    can_async_resume: false,
1125                    pre_parsed_args: None,
1126                },
1127            )
1128            .await
1129            .unwrap();
1130
1131        let payload: Value = serde_json::from_str(&result.result).unwrap();
1132        let expected =
1133            bamboo_config::paths::path_to_display_string(&nested.canonicalize().unwrap());
1134        assert_eq!(payload["cwd"].as_str().unwrap_or_default(), expected);
1135    }
1136
1137    #[tokio::test]
1138    async fn bash_rejects_workdir_that_is_not_directory() {
1139        prime_test_command_environment();
1140        let tool = BashTool::new();
1141        let file = tempfile::NamedTempFile::new().unwrap();
1142
1143        let result = tool
1144            .execute(json!({
1145                "command": "echo hello",
1146                "workdir": file.path()
1147            }))
1148            .await;
1149
1150        assert!(
1151            matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("directory"))
1152        );
1153    }
1154
1155    /// `running_shells_for_session` reports only the running shells tagged with the
1156    /// requested session, excluding completed shells and shells owned by another
1157    /// session (or none). (issue #84, phase 2a.)
1158    #[cfg(not(target_os = "windows"))]
1159    #[tokio::test]
1160    async fn running_shells_for_session_filters_by_session_and_status() {
1161        prime_test_command_environment();
1162
1163        // Two long-running shells owned by sess-A.
1164        let a1 = super::bash_runtime::spawn_background(
1165            "sleep 30",
1166            None,
1167            None,
1168            Some("sess-A".to_string()),
1169            false,
1170        )
1171        .await
1172        .expect("spawn a1");
1173        let a2 = super::bash_runtime::spawn_background(
1174            "sleep 30",
1175            None,
1176            None,
1177            Some("sess-A".to_string()),
1178            false,
1179        )
1180        .await
1181        .expect("spawn a2");
1182        // One long-running shell owned by sess-B.
1183        let b = super::bash_runtime::spawn_background(
1184            "sleep 30",
1185            None,
1186            None,
1187            Some("sess-B".to_string()),
1188            false,
1189        )
1190        .await
1191        .expect("spawn b");
1192        // An untagged (None) long-running shell.
1193        let untagged = super::bash_runtime::spawn_background("sleep 30", None, None, None, false)
1194            .await
1195            .expect("spawn untagged");
1196        // A sess-A shell that completes immediately — must be excluded once done.
1197        let done = super::bash_runtime::spawn_background(
1198            "true",
1199            None,
1200            None,
1201            Some("sess-A".to_string()),
1202            false,
1203        )
1204        .await
1205        .expect("spawn done");
1206
1207        // Wait for the fast shell to reach "completed" so we exercise the status filter.
1208        let started = Instant::now();
1209        loop {
1210            if done.status() == "completed" {
1211                break;
1212            }
1213            if started.elapsed() > Duration::from_secs(3) {
1214                panic!("sess-A fast shell never completed");
1215            }
1216            sleep(Duration::from_millis(25)).await;
1217        }
1218
1219        // sess-A should report exactly the two long-running shells, order-independent.
1220        let mut running = super::bash_runtime::running_shells_for_session("sess-A");
1221        running.sort();
1222        let mut expected = vec![a1.id.clone(), a2.id.clone()];
1223        expected.sort();
1224        assert_eq!(running, expected);
1225
1226        // sess-B should report exactly its own single running shell.
1227        assert_eq!(
1228            super::bash_runtime::running_shells_for_session("sess-B"),
1229            vec![b.id.clone()]
1230        );
1231
1232        // Cleanup: kill the still-running shells so the GC isn't left holding them,
1233        // and drop the already-completed `done` shell from the process-global registry.
1234        for shell in [a1, a2, b, untagged] {
1235            let _ = shell.kill().await;
1236        }
1237        let _ = super::bash_runtime::remove_shell(&done.id);
1238    }
1239
1240    // ── Auto-sync promotion tests (issue #84, phase 2d) ──────────────────
1241
1242    // (a) A fast command via the auto (None) path returns a synchronous result
1243    // with stdout + exit_code and no bash_id — identical to the old foreground.
1244    #[cfg(not(target_os = "windows"))]
1245    #[tokio::test]
1246    async fn auto_path_fast_command_returns_synchronous_result() {
1247        prime_test_command_environment();
1248        let tool = BashTool::new();
1249        let (tx, _rx) = mpsc::channel(32);
1250        let ctx = ToolExecutionContext {
1251            session_id: Some("session_auto_fast"),
1252            tool_call_id: "call_auto_fast",
1253            event_tx: Some(&tx),
1254            available_tool_schemas: None,
1255            bypass_permissions: false,
1256            can_async_resume: false,
1257            pre_parsed_args: None,
1258        };
1259
1260        let result = tool
1261            .execute_with_context(json!({ "command": "echo auto-fast-output" }), ctx)
1262            .await
1263            .expect("auto fast command should succeed");
1264
1265        let payload: Value = serde_json::from_str(&result.result).unwrap();
1266        assert!(
1267            payload.get("bash_id").is_none(),
1268            "fast auto command must not return a bash_id"
1269        );
1270        assert_eq!(payload["exit_code"], 0);
1271        assert_eq!(payload["timed_out"], false);
1272        assert!(payload["stdout"]
1273            .as_str()
1274            .unwrap_or_default()
1275            .contains("auto-fast-output"));
1276    }
1277
1278    // (b) Promotion: with a low test threshold, a long command (sleep) on the
1279    // auto path returns a background {bash_id, running} result, the adopted
1280    // shell appears in running_shells_for_session, and later emits
1281    // BashCompleted with the right id.
1282    #[cfg(not(target_os = "windows"))]
1283    #[tokio::test]
1284    async fn auto_path_promotes_long_command_to_background() {
1285        prime_test_command_environment();
1286        let tool = BashTool::new();
1287        let session_id = "session_auto_promote";
1288        let (tx, mut rx) = mpsc::channel(8);
1289        let ctx = ToolExecutionContext::for_dispatch(
1290            session_id,
1291            "call_auto_promote",
1292            &tx,
1293            &[],
1294            ToolExecutionSessionFlags::default(),
1295            // `can_async_resume` is irrelevant here — this test drives
1296            // promotion directly via run_streaming_command(Some(200)).
1297            true,
1298            None,
1299        );
1300        let cwd = super::workspace_state::workspace_or_process_cwd(Some(session_id));
1301
1302        // Call run_streaming_command directly with a 200ms promotion threshold
1303        // so this test exercises promotion deterministically without depending
1304        // on the production 10s default or the None dispatch arm's
1305        // `can_async_resume` gating.
1306        let result = tool
1307            .run_streaming_command("sleep 10", 60000, Some(200), &cwd, ctx)
1308            .await
1309            .expect("auto promote should succeed");
1310
1311        assert!(result.success);
1312        let payload: Value = serde_json::from_str(&result.result).unwrap();
1313        let bash_id = payload["bash_id"]
1314            .as_str()
1315            .expect("promoted result must have bash_id")
1316            .to_string();
1317        assert_eq!(payload["status"], "running");
1318
1319        let running = super::bash_runtime::running_shells_for_session(session_id);
1320        assert!(
1321            running.contains(&bash_id),
1322            "adopted shell {bash_id} must appear in running_shells, got {running:?}"
1323        );
1324
1325        let event = tokio::time::timeout(Duration::from_secs(15), rx.recv())
1326            .await
1327            .expect("timed out waiting for BashCompleted")
1328            .expect("event channel closed before BashCompleted");
1329        match event {
1330            AgentEvent::BashCompleted {
1331                bash_id: id,
1332                exit_code,
1333                status,
1334                ..
1335            } => {
1336                assert_eq!(id, bash_id);
1337                assert_eq!(exit_code, Some(0));
1338                assert_eq!(status, "completed");
1339            }
1340            other => panic!("expected BashCompleted, got {other:?}"),
1341        }
1342
1343        if let Some(shell) = super::bash_runtime::get_shell(&bash_id) {
1344            let _ = shell.kill().await;
1345        }
1346    }
1347
1348    // (c) run_in_background:Some(false) does NOT promote — blocks/times out
1349    // like the pre-2d foreground path.
1350    #[cfg(not(target_os = "windows"))]
1351    #[tokio::test]
1352    async fn force_sync_does_not_promote_and_times_out() {
1353        prime_test_command_environment();
1354        let tool = BashTool::new();
1355
1356        let result = tool
1357            .execute(json!({
1358                "command": "sleep 10",
1359                "run_in_background": false,
1360                "timeout": 50
1361            }))
1362            .await
1363            .expect("force-sync should produce a timed-out result");
1364
1365        let payload: Value = serde_json::from_str(&result.result).unwrap();
1366        assert!(
1367            payload.get("bash_id").is_none(),
1368            "force-sync must never promote"
1369        );
1370        assert_eq!(payload["timed_out"], true);
1371        assert!(!result.success, "timed-out result must not be successful");
1372    }
1373
1374    // (e) Auto path (run_in_background OMITTED) with can_async_resume == false
1375    // also does NOT promote — it runs purely synchronously, exactly like (c),
1376    // even though promotion is now the default. This is the hook-less-path guard
1377    // (issue #84, phase 2d): a loop that can't suspend+resume a background shell
1378    // (schedule path, agent-core loop) must never orphan one via auto-promotion.
1379    // `execute()` builds `ToolExecutionContext::none`, whose can_async_resume is
1380    // false, so the None dispatch arm passes promote_after_ms = None.
1381    #[cfg(not(target_os = "windows"))]
1382    #[tokio::test]
1383    async fn auto_path_does_not_promote_when_not_resume_capable() {
1384        prime_test_command_environment();
1385        let tool = BashTool::new();
1386
1387        let result = tool
1388            .execute(json!({
1389                "command": "sleep 10",
1390                "timeout": 50
1391            }))
1392            .await
1393            .expect("non-resume-capable auto path should produce a result");
1394
1395        let payload: Value = serde_json::from_str(&result.result).unwrap();
1396        assert!(
1397            payload.get("bash_id").is_none(),
1398            "auto path must not promote when can_async_resume is false"
1399        );
1400        assert_eq!(payload["timed_out"], true);
1401        assert!(
1402            !result.success,
1403            "a timed-out command must not report success"
1404        );
1405    }
1406
1407    // (d) adopt_running_child preserves already-captured output: seed lines
1408    // appear in subsequent read_output_since calls.
1409    #[cfg(not(target_os = "windows"))]
1410    #[tokio::test]
1411    async fn adopt_running_child_preserves_seeded_output() {
1412        prime_test_command_environment();
1413        let shell = bamboo_infrastructure::process::preferred_bash_shell();
1414        let mut cmd = tokio::process::Command::new(&shell.program);
1415        bamboo_infrastructure::process::hide_window_for_tokio_command(&mut cmd);
1416        cmd.arg(shell.arg)
1417            .arg("echo seeded-line-1; echo seeded-line-2; sleep 5")
1418            .stdin(std::process::Stdio::null())
1419            .stdout(std::process::Stdio::piped())
1420            .stderr(std::process::Stdio::piped())
1421            .kill_on_drop(true);
1422        let mut child = cmd.spawn().expect("spawn child");
1423        let stdout_reader = tokio::io::BufReader::new(child.stdout.take().unwrap());
1424        let stderr_reader = tokio::io::BufReader::new(child.stderr.take().unwrap());
1425
1426        // Let the echo output land in the pipe buffer before adoption.
1427        sleep(Duration::from_millis(200)).await;
1428
1429        let session = super::bash_runtime::adopt_running_child(
1430            child,
1431            stdout_reader,
1432            stderr_reader,
1433            vec!["seeded-line-1".to_string(), "seeded-line-2".to_string()],
1434            vec![],
1435            "echo seeded-line-1; echo seeded-line-2; sleep 5",
1436            Some("session_seed_test".to_string()),
1437            test_environment_diagnostics(),
1438            None,
1439        )
1440        .await
1441        .expect("adopt should succeed");
1442
1443        let (lines, _cursor, _dropped) = session.read_output_since(0, None).await;
1444        assert!(
1445            lines.iter().any(|l| l.contains("seeded-line-1")),
1446            "seeded line 1 must be present, got {lines:?}"
1447        );
1448        assert!(
1449            lines.iter().any(|l| l.contains("seeded-line-2")),
1450            "seeded line 2 must be present, got {lines:?}"
1451        );
1452
1453        let _ = session.kill().await;
1454        let _ = super::bash_runtime::remove_shell(&session.id);
1455    }
1456}