Skip to main content

bamboo_tools/tools/
bash.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolCtx, ToolError, ToolOutcome, 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: ToolCtx,
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                ctx.cloned_bash_completion_sink(),
417            )
418            .await
419            .map_err(ToolError::Execution)?;
420
421            return Ok(ToolResult {
422                success: true,
423                result: json!({
424                    "bash_id": session.id,
425                    "command": session.command,
426                    "status": "running",
427                    "cwd": bamboo_config::paths::path_to_display_string(cwd),
428                    "environment": Self::environment_json(&session.environment, false),
429                })
430                .to_string(),
431                display_preference: Some("Collapsible".to_string()),
432                images: Vec::new(),
433            });
434        }
435
436        // Timeout fired (promotion disabled or timeout <= promote threshold).
437        let _ = child.kill().await;
438        let cwd_display = bamboo_config::paths::path_to_display_string(cwd);
439        let environment = Self::environment_json(&prepared_env.diagnostics, true);
440
441        Ok(ToolResult {
442            success: false,
443            result: json!({
444                "command": command,
445                "cwd": cwd_display,
446                "stdout": stdout_buf,
447                "stderr": stderr_buf,
448                "exit_code": serde_json::Value::Null,
449                "timed_out": true,
450                "stdout_truncated": stdout_truncated,
451                "stderr_truncated": stderr_truncated,
452                "environment": environment,
453            })
454            .to_string(),
455            display_preference: Some("Collapsible".to_string()),
456            images: Vec::new(),
457        })
458    }
459}
460
461impl Default for BashTool {
462    fn default() -> Self {
463        Self::new()
464    }
465}
466
467#[async_trait]
468impl Tool for BashTool {
469    fn name(&self) -> &str {
470        "Bash"
471    }
472
473    fn description(&self) -> &str {
474        "Execute shell commands with streaming output (supports background mode). \
475         By default (run_in_background omitted), commands run synchronously but are \
476         auto-promoted to background if they run longer than ~10s — fast commands \
477         behave exactly as foreground. Set run_in_background to false to force \
478         synchronous (block until timeout), or true to force immediate background. \
479         Set interactive to true to spawn in the background with a piped stdin so \
480         input can be fed over time via BashInput (interactive implies background; \
481         use it only to answer an interactive prompt). A backgrounded command runs \
482         detached and does NOT block the loop: keep working, and when it finishes \
483         you are automatically notified with a message carrying its exit status and \
484         a tail of its output — you do NOT need to poll. Use BashOutput only when \
485         you want the full output before then; KillShell to stop it early. Default \
486         timeout is 120000ms (max 600000ms); captured stdout/stderr are each \
487         capped at 512KB."
488    }
489
490    fn parameters_schema(&self) -> serde_json::Value {
491        json!({
492            "type": "object",
493            "properties": {
494                "command": {
495                    "type": "string",
496                    "description": "The command to execute"
497                },
498                "timeout": {
499                    "type": "number",
500                    "description": "Optional timeout in milliseconds (default 120000, max 600000)"
501                },
502                "description": {
503                    "type": "string",
504                    "description": "Optional short context label for the command"
505                },
506                "run_in_background": {
507                    "type": "boolean",
508                    "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: returns a bash_id at once and runs detached; you are notified with the result (exit status + output tail) when it finishes, so keep working instead of polling. BashOutput is available for the full log; KillShell stops it early."
509                },
510                "interactive": {
511                    "type": "boolean",
512                    "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."
513                },
514                "workdir": {
515                    "type": "string",
516                    "description": "Optional working directory. Relative paths are resolved from the session workspace."
517                }
518            },
519            "required": ["command"],
520            "additionalProperties": false
521        })
522    }
523
524    async fn invoke(
525        &self,
526        args: serde_json::Value,
527        ctx: ToolCtx,
528    ) -> Result<ToolOutcome, ToolError> {
529        let parsed: BashArgs = serde_json::from_value(args)
530            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Bash args: {}", e)))?;
531
532        let command = parsed.command.trim();
533        if command.is_empty() {
534            return Err(ToolError::InvalidArguments(
535                "'command' cannot be empty".to_string(),
536            ));
537        }
538
539        let _ = parsed.description;
540        let timeout_ms = Self::effective_timeout_ms(parsed.timeout);
541        let session_workspace = workspace_state::workspace_or_process_cwd(ctx.session_id());
542        let cwd = Self::resolve_cwd(&session_workspace, parsed.workdir.as_deref())?;
543
544        if parsed.interactive == Some(true) {
545            // Interactive (issue #89): spawn in the background with a piped
546            // stdin so the shell can be fed input over time via BashInput.
547            // interactive:true implies run_in_background — return a bash_id
548            // immediately. The non-interactive paths below keep Stdio::null(),
549            // so default EOF-on-read is byte-for-byte unchanged.
550            let shell = bash_runtime::spawn_background(
551                command,
552                Some(&cwd),
553                ctx.cloned_sender(),
554                ctx.session_id().map(str::to_string),
555                true,
556                ctx.cloned_bash_completion_sink(),
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(ToolOutcome::Completed(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                    ctx.cloned_bash_completion_sink(),
598                )
599                .await
600                .map_err(ToolError::Execution)?;
601
602                if let Some(requested_timeout) = parsed.timeout {
603                    let kill_after_ms = Self::effective_timeout_ms(Some(requested_timeout));
604                    let shell_clone = shell.clone();
605                    tokio::spawn(async move {
606                        tokio::time::sleep(Duration::from_millis(kill_after_ms)).await;
607                        if shell_clone.status() == "running" {
608                            let _ = shell_clone.kill().await;
609                        }
610                    });
611                }
612
613                Ok(ToolOutcome::Completed(ToolResult {
614                    success: true,
615                    result: json!({
616                        "bash_id": shell.id,
617                        "command": shell.command,
618                        "status": "running",
619                        "cwd": bamboo_config::paths::path_to_display_string(&cwd),
620                        "environment": Self::environment_json(&shell.environment, false),
621                    })
622                    .to_string(),
623                    display_preference: Some("Collapsible".to_string()),
624                    images: Vec::new(),
625                }))
626            }
627            Some(false) => {
628                // Force synchronous — pure foreground, no promotion (issue #84,
629                // phase 2d). Blocks until the command completes or times out,
630                // exactly like the pre-2d behavior.
631                self.run_streaming_command(command, timeout_ms, None, &cwd, ctx)
632                    .await
633                    .map(ToolOutcome::Completed)
634            }
635            None => {
636                // Auto-sync promotion (issue #84, phase 2d). Runs foreground but
637                // promotes to background if still running after the promotion
638                // threshold (~10s). Fast commands finish synchronously. Promotion
639                // is ONLY enabled when the executing loop can actually suspend
640                // for and self-resume a backgrounded shell (`can_async_resume`):
641                // on hook-less paths (schedule / external-child loops) it stays
642                // purely synchronous so a long command's output is never orphaned.
643                let promote_after_ms = if ctx.can_async_resume {
644                    Some(PROMOTE_TO_BACKGROUND_AFTER_MS)
645                } else {
646                    None
647                };
648                self.run_streaming_command(command, timeout_ms, promote_after_ms, &cwd, ctx)
649                    .await
650                    .map(ToolOutcome::Completed)
651            }
652        }
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use bamboo_agent_core::tools::ToolExecutionSessionFlags;
660    use bamboo_agent_core::{AgentEvent, ToolExecutionContext};
661    use bamboo_infrastructure::process::{
662        clear_command_environment_cache_for_tests, prime_command_environment_cache_for_tests,
663        CommandEnvironmentDiagnostics, CommandEnvironmentSource, PythonDiscoveryDiagnostics,
664    };
665    use serde_json::Value;
666    use std::collections::HashMap;
667    use tokio::sync::mpsc;
668    use tokio::time::{sleep, Duration, Instant};
669
670    #[cfg(target_os = "windows")]
671    fn mixed_output_command() -> &'static str {
672        "echo out && echo err 1>&2"
673    }
674
675    #[cfg(not(target_os = "windows"))]
676    fn mixed_output_command() -> &'static str {
677        "printf 'out\\n'; printf 'err\\n' 1>&2"
678    }
679
680    #[cfg(target_os = "windows")]
681    fn invalid_utf8_stderr_command() -> String {
682        let shell = bamboo_infrastructure::process::preferred_bash_shell();
683        if shell.arg == "-lc" {
684            "printf '\\377\\n' 1>&2".to_string()
685        } else {
686            "powershell -NoProfile -Command \"$bytes = [byte[]](0xFF,0x0A); [Console]::OpenStandardError().Write($bytes,0,$bytes.Length)\"".to_string()
687        }
688    }
689
690    #[cfg(not(target_os = "windows"))]
691    fn invalid_utf8_stderr_command() -> String {
692        "printf '\\377\\n' 1>&2".to_string()
693    }
694
695    fn test_environment_diagnostics() -> CommandEnvironmentDiagnostics {
696        CommandEnvironmentDiagnostics {
697            source: CommandEnvironmentSource::InheritedProcess,
698            import_shell: None,
699            import_error: Some("test-import-disabled".to_string()),
700            path: Some("/usr/bin:/bin".to_string()),
701            path_entries: Some(2),
702            python: PythonDiscoveryDiagnostics {
703                configured: Some("python3".to_string()),
704                resolved: Some("/usr/bin/python3".to_string()),
705                invocation: Some("/usr/bin/python3".to_string()),
706                source: Some("path".to_string()),
707                tried: vec!["python3".to_string(), "python".to_string()],
708                tried_preview: vec!["python3".to_string(), "python".to_string()],
709                tried_total: 2,
710                tried_truncated: false,
711                hint: None,
712            },
713        }
714    }
715
716    fn prime_test_command_environment() {
717        clear_command_environment_cache_for_tests();
718        prime_command_environment_cache_for_tests(
719            HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]),
720            test_environment_diagnostics(),
721        );
722    }
723
724    #[tokio::test]
725    async fn bash_foreground_returns_stdout_stderr_and_streams_tokens() {
726        prime_test_command_environment();
727        let tool = BashTool::new();
728        let (tx, mut rx) = mpsc::channel(32);
729
730        let out = tool
731            .invoke(
732                json!({
733                    "command": mixed_output_command()
734                }),
735                ToolExecutionContext {
736                    session_id: Some("session_1"),
737                    tool_call_id: "call_1",
738                    event_tx: Some(&tx),
739                    available_tool_schemas: None,
740                    bypass_permissions: false,
741                    can_async_resume: false,
742                    bash_completion_sink: None,
743                    pre_parsed_args: None,
744                }
745                .to_tool_ctx(),
746            )
747            .await
748            .unwrap();
749        let ToolOutcome::Completed(result) = out else {
750            panic!("expected Completed")
751        };
752
753        assert!(result.success);
754
755        let payload: Value = serde_json::from_str(&result.result).unwrap();
756        assert_eq!(payload["timed_out"], false);
757        assert_eq!(payload["exit_code"], 0);
758        assert!(payload["stdout"]
759            .as_str()
760            .unwrap_or_default()
761            .contains("out"));
762        assert!(payload["stderr"]
763            .as_str()
764            .unwrap_or_default()
765            .contains("err"));
766        assert_eq!(payload["environment"]["source"], "process_env");
767        assert_eq!(
768            payload["environment"]["import_error"],
769            "test-import-disabled"
770        );
771        assert_eq!(
772            payload["environment"]["python"]["resolved"],
773            "/usr/bin/python3"
774        );
775        assert_eq!(
776            payload["environment"]["python"]["invocation"],
777            "/usr/bin/python3"
778        );
779        assert_eq!(payload["environment"]["python"]["source"], "path");
780        assert_eq!(
781            payload["environment"]["python"]["tried_preview"][0],
782            "python3"
783        );
784        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
785        assert!(payload["environment"]["python"].get("tried").is_none());
786
787        let mut streamed = Vec::new();
788        while let Ok(event) = rx.try_recv() {
789            if let AgentEvent::ToolToken { content, .. } = event {
790                streamed.push(content);
791            }
792        }
793
794        assert!(streamed.iter().any(|line| line.contains("out")));
795        assert!(streamed.iter().any(|line| line.contains("err")));
796    }
797
798    #[tokio::test]
799    async fn bash_foreground_tolerates_invalid_utf8_stderr() {
800        prime_test_command_environment();
801        let tool = BashTool::new();
802        let result = tool
803            .invoke(
804                json!({
805                    "command": invalid_utf8_stderr_command()
806                }),
807                ToolCtx::none("t"),
808            )
809            .await;
810
811        assert!(result.is_ok(), "invalid UTF-8 stderr should not fail");
812        let ToolOutcome::Completed(result) = result.unwrap() else {
813            panic!("expected Completed")
814        };
815        let payload: Value = serde_json::from_str(&result.result).unwrap();
816        let stderr = payload["stderr"].as_str().unwrap_or_default();
817        assert!(!stderr.is_empty());
818    }
819
820    #[cfg(not(target_os = "windows"))]
821    #[tokio::test]
822    async fn bash_foreground_failure_includes_full_python_tried_list() {
823        prime_test_command_environment();
824        let tool = BashTool::new();
825        let out = tool
826            .invoke(
827                json!({
828                    "command": "false"
829                }),
830                ToolCtx::none("t"),
831            )
832            .await
833            .unwrap();
834        let ToolOutcome::Completed(result) = out else {
835            panic!("expected Completed")
836        };
837
838        assert!(!result.success);
839        let payload: Value = serde_json::from_str(&result.result).unwrap();
840        assert_eq!(payload["exit_code"], 1);
841        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
842        assert_eq!(payload["environment"]["python"]["tried"][0], "python3");
843    }
844
845    #[cfg(not(target_os = "windows"))]
846    #[tokio::test]
847    async fn bash_foreground_sets_stdout_truncated_when_output_exceeds_cap() {
848        prime_test_command_environment();
849        let tool = BashTool::new();
850        let out = tool
851            .invoke(
852                json!({
853                    "command": "i=0; while [ $i -lt 70000 ]; do printf 'aaaaaaaaaa'; i=$((i+1)); done; printf '\\n'"
854                }),
855                ToolCtx::none("t"),
856            )
857            .await
858            .unwrap();
859        let ToolOutcome::Completed(result) = out else {
860            panic!("expected Completed")
861        };
862
863        let payload: Value = serde_json::from_str(&result.result).unwrap();
864        assert_eq!(payload["timed_out"], false);
865        assert_eq!(payload["stdout_truncated"], true);
866    }
867
868    #[cfg(not(target_os = "windows"))]
869    #[tokio::test]
870    async fn bash_background_honors_explicit_timeout() {
871        prime_test_command_environment();
872        let tool = BashTool::new();
873        let out = tool
874            .invoke(
875                json!({
876                    "command": "sleep 2",
877                    "run_in_background": true,
878                    "timeout": 50
879                }),
880                ToolCtx::none("t"),
881            )
882            .await
883            .unwrap();
884        let ToolOutcome::Completed(result) = out else {
885            panic!("expected Completed")
886        };
887        let payload: Value = serde_json::from_str(&result.result).unwrap();
888        assert_eq!(payload["environment"]["source"], "process_env");
889        assert_eq!(
890            payload["environment"]["python"]["resolved"],
891            "/usr/bin/python3"
892        );
893        assert_eq!(
894            payload["environment"]["python"]["invocation"],
895            "/usr/bin/python3"
896        );
897        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
898        assert!(payload["environment"]["python"].get("tried").is_none());
899        let shell_id = payload["bash_id"].as_str().unwrap().to_string();
900
901        let started = Instant::now();
902        loop {
903            let shell = super::bash_runtime::get_shell(&shell_id).unwrap();
904            if shell.status() == "completed" {
905                break;
906            }
907            if started.elapsed() > Duration::from_secs(2) {
908                panic!("background shell did not stop after timeout");
909            }
910            sleep(Duration::from_millis(25)).await;
911        }
912    }
913
914    /// A short background command must emit a `BashCompleted` signal carrying the
915    /// session's `bash_id` and an exit code of `Some(0)` (issue #84, phase 1).
916    #[cfg(not(target_os = "windows"))]
917    #[tokio::test]
918    async fn bash_background_emits_completion_event_with_exit_code() {
919        prime_test_command_environment();
920        let (tx, mut rx) = mpsc::channel(8);
921        let shell = super::bash_runtime::spawn_background("true", None, Some(tx), None, false, None)
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                command,
935                exit_code,
936                status,
937            } => {
938                assert_eq!(bash_id, expected_id);
939                assert_eq!(command, "true");
940                assert_eq!(exit_code, Some(0));
941                assert_eq!(status, "completed");
942            }
943            other => panic!("expected BashCompleted, got {other:?}"),
944        }
945    }
946
947    /// A failing background command still reports `status="completed"` with its
948    /// non-zero exit code — a non-zero exit is a normal completion, not a kill.
949    #[cfg(not(target_os = "windows"))]
950    #[tokio::test]
951    async fn bash_background_emits_completion_event_for_failing_command() {
952        prime_test_command_environment();
953        let (tx, mut rx) = mpsc::channel(8);
954        let shell = super::bash_runtime::spawn_background("false", None, Some(tx), None, false, None)
955            .await
956            .expect("background shell should spawn");
957        let expected_id = shell.id.clone();
958
959        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
960            .await
961            .expect("timed out waiting for BashCompleted event")
962            .expect("event channel closed before BashCompleted");
963
964        match event {
965            AgentEvent::BashCompleted {
966                bash_id,
967                exit_code,
968                status,
969                ..
970            } => {
971                assert_eq!(bash_id, expected_id);
972                assert_eq!(exit_code, Some(1));
973                assert_eq!(status, "completed");
974            }
975            other => panic!("expected BashCompleted, got {other:?}"),
976        }
977    }
978
979    /// A killed background command must report `status="killed"` with
980    /// `exit_code=None` (no numeric code for signal termination on Unix).
981    #[cfg(not(target_os = "windows"))]
982    #[tokio::test]
983    async fn bash_background_emits_killed_when_shell_is_killed() {
984        prime_test_command_environment();
985        let (tx, mut rx) = mpsc::channel(8);
986        let shell = super::bash_runtime::spawn_background("sleep 30", None, Some(tx), None, false, None)
987            .await
988            .expect("background shell should spawn");
989        let expected_id = shell.id.clone();
990
991        shell.kill().await.expect("shell should be killable");
992
993        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
994            .await
995            .expect("timed out waiting for BashCompleted event")
996            .expect("event channel closed before BashCompleted");
997
998        match event {
999            AgentEvent::BashCompleted {
1000                bash_id,
1001                exit_code,
1002                status,
1003                ..
1004            } => {
1005                assert_eq!(bash_id, expected_id);
1006                assert_eq!(exit_code, None);
1007                assert_eq!(status, "killed");
1008            }
1009            other => panic!("expected BashCompleted, got {other:?}"),
1010        }
1011    }
1012
1013    /// With no event sender, the poll task must skip the emit entirely and still
1014    /// flip the shell to "completed" (issue #84, phase 1).
1015    #[cfg(not(target_os = "windows"))]
1016    #[tokio::test]
1017    async fn bash_background_without_sender_still_completes() {
1018        prime_test_command_environment();
1019        let shell = super::bash_runtime::spawn_background("true", None, None, None, false, None)
1020            .await
1021            .expect("background shell should spawn");
1022
1023        let started = Instant::now();
1024        loop {
1025            if shell.status() == "completed" {
1026                break;
1027            }
1028            if started.elapsed() > Duration::from_secs(3) {
1029                panic!("shell never reached completed without a sender");
1030            }
1031            sleep(Duration::from_millis(25)).await;
1032        }
1033    }
1034
1035    /// A saturated event channel must not hang or panic the poll task: the
1036    /// completion send hits the 500ms bounded-timeout path, logs a `warn!`, and
1037    /// the shell still completes. The signal is dropped (observable), not lost
1038    /// silently. The channel is kept full past the timeout window so the send is
1039    /// guaranteed to time out rather than succeed when a slot is freed.
1040    #[cfg(not(target_os = "windows"))]
1041    #[tokio::test]
1042    async fn bash_background_drops_completion_when_channel_saturated() {
1043        prime_test_command_environment();
1044        // Capacity-1 channel pre-filled so the single slot is occupied.
1045        let (tx, mut rx) = mpsc::channel::<AgentEvent>(1);
1046        tx.try_send(AgentEvent::Token {
1047            content: "occupy".into(),
1048        })
1049        .expect("prefill channel slot");
1050
1051        let shell = super::bash_runtime::spawn_background("true", None, Some(tx), None, false, None)
1052            .await
1053            .expect("background shell should spawn");
1054
1055        // Wait past the 500ms bounded-send window so the dropped BashCompleted
1056        // has been observed and the poll task has moved on.
1057        sleep(Duration::from_millis(650)).await;
1058
1059        // The only event ever delivered is the pre-filled token; BashCompleted
1060        // was dropped (saturated channel) and never enqueued.
1061        let only = rx
1062            .recv()
1063            .await
1064            .expect("prefilled token should still be present");
1065        assert!(
1066            matches!(only, AgentEvent::Token { .. }),
1067            "expected only the pre-filled token, got {only:?}"
1068        );
1069        assert!(
1070            tokio::time::timeout(Duration::from_millis(50), rx.recv())
1071                .await
1072                .is_err(),
1073            "no BashCompleted should be delivered after a saturation drop"
1074        );
1075        assert_eq!(
1076            shell.status(),
1077            "completed",
1078            "shell must still reach completed after a dropped signal"
1079        );
1080    }
1081
1082    /// Drives the real tool dispatch path: `BashTool::execute_with_context`
1083    /// with `run_in_background=true` and a context built via `for_dispatch`
1084    /// carrying an `event_tx`, so the signal flows through `ctx.cloned_sender()`
1085    /// (the production wiring), not just `spawn_background` directly.
1086    #[cfg(not(target_os = "windows"))]
1087    #[tokio::test]
1088    async fn bash_tool_background_dispatch_emits_completion_event() {
1089        prime_test_command_environment();
1090        let tool = BashTool::new();
1091        let (tx, mut rx) = mpsc::channel(8);
1092        let ctx = ToolExecutionContext::for_dispatch(
1093            "session_84",
1094            "call_84",
1095            &tx,
1096            &[],
1097            ToolExecutionSessionFlags::default(),
1098            true,
1099            None,
1100            None,
1101        );
1102
1103        let out = tool
1104            .invoke(
1105                json!({ "command": "true", "run_in_background": true }),
1106                ctx.to_tool_ctx(),
1107            )
1108            .await
1109            .expect("background dispatch should succeed");
1110        let ToolOutcome::Completed(result) = out else {
1111            panic!("expected Completed")
1112        };
1113        assert!(result.success);
1114
1115        let payload: Value = serde_json::from_str(&result.result).unwrap();
1116        let bash_id = payload["bash_id"].as_str().unwrap().to_string();
1117        assert_eq!(payload["status"], "running");
1118
1119        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1120            .await
1121            .expect("timed out waiting for BashCompleted event")
1122            .expect("event channel closed before BashCompleted");
1123
1124        match event {
1125            AgentEvent::BashCompleted {
1126                bash_id: id,
1127                exit_code,
1128                status,
1129                ..
1130            } => {
1131                assert_eq!(id, bash_id);
1132                assert_eq!(exit_code, Some(0));
1133                assert_eq!(status, "completed");
1134            }
1135            other => panic!("expected BashCompleted, got {other:?}"),
1136        }
1137    }
1138
1139    #[tokio::test]
1140    async fn bash_resolves_relative_workdir_from_session_workspace() {
1141        prime_test_command_environment();
1142        let tool = BashTool::new();
1143        let dir = tempfile::tempdir().unwrap();
1144        let base = dir.path().join("base");
1145        let nested = base.join("nested");
1146        tokio::fs::create_dir_all(&nested).await.unwrap();
1147
1148        let session_id = format!("session_{}", uuid::Uuid::new_v4());
1149        super::workspace_state::set_workspace(&session_id, base.canonicalize().unwrap());
1150
1151        let out = tool
1152            .invoke(
1153                json!({
1154                    "command": "pwd",
1155                    "workdir": "nested"
1156                }),
1157                ToolExecutionContext {
1158                    session_id: Some(&session_id),
1159                    tool_call_id: "call_1",
1160                    event_tx: None,
1161                    available_tool_schemas: None,
1162                    bypass_permissions: false,
1163                    can_async_resume: false,
1164                    bash_completion_sink: None,
1165                    pre_parsed_args: None,
1166                }
1167                .to_tool_ctx(),
1168            )
1169            .await
1170            .unwrap();
1171        let ToolOutcome::Completed(result) = out else {
1172            panic!("expected Completed")
1173        };
1174
1175        let payload: Value = serde_json::from_str(&result.result).unwrap();
1176        let expected =
1177            bamboo_config::paths::path_to_display_string(&nested.canonicalize().unwrap());
1178        assert_eq!(payload["cwd"].as_str().unwrap_or_default(), expected);
1179    }
1180
1181    #[tokio::test]
1182    async fn bash_rejects_workdir_that_is_not_directory() {
1183        prime_test_command_environment();
1184        let tool = BashTool::new();
1185        let file = tempfile::NamedTempFile::new().unwrap();
1186
1187        let result = tool
1188            .invoke(
1189                json!({
1190                    "command": "echo hello",
1191                    "workdir": file.path()
1192                }),
1193                ToolCtx::none("t"),
1194            )
1195            .await;
1196
1197        assert!(
1198            matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("directory"))
1199        );
1200    }
1201
1202    /// `running_shells_for_session` reports only the running shells tagged with the
1203    /// requested session, excluding completed shells and shells owned by another
1204    /// session (or none). (issue #84, phase 2a.)
1205    #[cfg(not(target_os = "windows"))]
1206    #[tokio::test]
1207    async fn running_shells_for_session_filters_by_session_and_status() {
1208        prime_test_command_environment();
1209
1210        // Two long-running shells owned by sess-A.
1211        let a1 = super::bash_runtime::spawn_background(
1212            "sleep 30",
1213            None,
1214            None,
1215            Some("sess-A".to_string()),
1216            false,
1217            None,
1218        )
1219        .await
1220        .expect("spawn a1");
1221        let a2 = super::bash_runtime::spawn_background(
1222            "sleep 30",
1223            None,
1224            None,
1225            Some("sess-A".to_string()),
1226            false,
1227            None,
1228        )
1229        .await
1230        .expect("spawn a2");
1231        // One long-running shell owned by sess-B.
1232        let b = super::bash_runtime::spawn_background(
1233            "sleep 30",
1234            None,
1235            None,
1236            Some("sess-B".to_string()),
1237            false,
1238            None,
1239        )
1240        .await
1241        .expect("spawn b");
1242        // An untagged (None) long-running shell.
1243        let untagged = super::bash_runtime::spawn_background("sleep 30", None, None, None, false, None)
1244            .await
1245            .expect("spawn untagged");
1246        // A sess-A shell that completes immediately — must be excluded once done.
1247        let done = super::bash_runtime::spawn_background(
1248            "true",
1249            None,
1250            None,
1251            Some("sess-A".to_string()),
1252            false,
1253            None,
1254        )
1255        .await
1256        .expect("spawn done");
1257
1258        // Wait for the fast shell to reach "completed" so we exercise the status filter.
1259        let started = Instant::now();
1260        loop {
1261            if done.status() == "completed" {
1262                break;
1263            }
1264            if started.elapsed() > Duration::from_secs(3) {
1265                panic!("sess-A fast shell never completed");
1266            }
1267            sleep(Duration::from_millis(25)).await;
1268        }
1269
1270        // sess-A should report exactly the two long-running shells, order-independent.
1271        let mut running = super::bash_runtime::running_shells_for_session("sess-A");
1272        running.sort();
1273        let mut expected = vec![a1.id.clone(), a2.id.clone()];
1274        expected.sort();
1275        assert_eq!(running, expected);
1276
1277        // sess-B should report exactly its own single running shell.
1278        assert_eq!(
1279            super::bash_runtime::running_shells_for_session("sess-B"),
1280            vec![b.id.clone()]
1281        );
1282
1283        // Cleanup: kill the still-running shells so the GC isn't left holding them,
1284        // and drop the already-completed `done` shell from the process-global registry.
1285        for shell in [a1, a2, b, untagged] {
1286            let _ = shell.kill().await;
1287        }
1288        let _ = super::bash_runtime::remove_shell(&done.id);
1289    }
1290
1291    // ── Auto-sync promotion tests (issue #84, phase 2d) ──────────────────
1292
1293    // (a) A fast command via the auto (None) path returns a synchronous result
1294    // with stdout + exit_code and no bash_id — identical to the old foreground.
1295    #[cfg(not(target_os = "windows"))]
1296    #[tokio::test]
1297    async fn auto_path_fast_command_returns_synchronous_result() {
1298        prime_test_command_environment();
1299        let tool = BashTool::new();
1300        let (tx, _rx) = mpsc::channel(32);
1301        let ctx = ToolExecutionContext {
1302            session_id: Some("session_auto_fast"),
1303            tool_call_id: "call_auto_fast",
1304            event_tx: Some(&tx),
1305            available_tool_schemas: None,
1306            bypass_permissions: false,
1307            can_async_resume: false,
1308            bash_completion_sink: None,
1309            pre_parsed_args: None,
1310        };
1311
1312        let out = tool
1313            .invoke(json!({ "command": "echo auto-fast-output" }), ctx.to_tool_ctx())
1314            .await
1315            .expect("auto fast command should succeed");
1316        let ToolOutcome::Completed(result) = out else {
1317            panic!("expected Completed")
1318        };
1319
1320        let payload: Value = serde_json::from_str(&result.result).unwrap();
1321        assert!(
1322            payload.get("bash_id").is_none(),
1323            "fast auto command must not return a bash_id"
1324        );
1325        assert_eq!(payload["exit_code"], 0);
1326        assert_eq!(payload["timed_out"], false);
1327        assert!(payload["stdout"]
1328            .as_str()
1329            .unwrap_or_default()
1330            .contains("auto-fast-output"));
1331    }
1332
1333    // (b) Promotion: with a low test threshold, a long command (sleep) on the
1334    // auto path returns a background {bash_id, running} result, the adopted
1335    // shell appears in running_shells_for_session, and later emits
1336    // BashCompleted with the right id.
1337    #[cfg(not(target_os = "windows"))]
1338    #[tokio::test]
1339    async fn auto_path_promotes_long_command_to_background() {
1340        prime_test_command_environment();
1341        let tool = BashTool::new();
1342        let session_id = "session_auto_promote";
1343        let (tx, mut rx) = mpsc::channel(8);
1344        let ctx = ToolExecutionContext::for_dispatch(
1345            session_id,
1346            "call_auto_promote",
1347            &tx,
1348            &[],
1349            ToolExecutionSessionFlags::default(),
1350            // `can_async_resume` is irrelevant here — this test drives
1351            // promotion directly via run_streaming_command(Some(200)).
1352            true,
1353            None,
1354            None,
1355        );
1356        let cwd = super::workspace_state::workspace_or_process_cwd(Some(session_id));
1357
1358        // Call run_streaming_command directly with a 200ms promotion threshold
1359        // so this test exercises promotion deterministically without depending
1360        // on the production 10s default or the None dispatch arm's
1361        // `can_async_resume` gating.
1362        let result = tool
1363            .run_streaming_command("sleep 10", 60000, Some(200), &cwd, ctx.to_tool_ctx())
1364            .await
1365            .expect("auto promote should succeed");
1366
1367        assert!(result.success);
1368        let payload: Value = serde_json::from_str(&result.result).unwrap();
1369        let bash_id = payload["bash_id"]
1370            .as_str()
1371            .expect("promoted result must have bash_id")
1372            .to_string();
1373        assert_eq!(payload["status"], "running");
1374
1375        let running = super::bash_runtime::running_shells_for_session(session_id);
1376        assert!(
1377            running.contains(&bash_id),
1378            "adopted shell {bash_id} must appear in running_shells, got {running:?}"
1379        );
1380
1381        let event = tokio::time::timeout(Duration::from_secs(15), rx.recv())
1382            .await
1383            .expect("timed out waiting for BashCompleted")
1384            .expect("event channel closed before BashCompleted");
1385        match event {
1386            AgentEvent::BashCompleted {
1387                bash_id: id,
1388                exit_code,
1389                status,
1390                ..
1391            } => {
1392                assert_eq!(id, bash_id);
1393                assert_eq!(exit_code, Some(0));
1394                assert_eq!(status, "completed");
1395            }
1396            other => panic!("expected BashCompleted, got {other:?}"),
1397        }
1398
1399        if let Some(shell) = super::bash_runtime::get_shell(&bash_id) {
1400            let _ = shell.kill().await;
1401        }
1402    }
1403
1404    // (c) run_in_background:Some(false) does NOT promote — blocks/times out
1405    // like the pre-2d foreground path.
1406    #[cfg(not(target_os = "windows"))]
1407    #[tokio::test]
1408    async fn force_sync_does_not_promote_and_times_out() {
1409        prime_test_command_environment();
1410        let tool = BashTool::new();
1411
1412        let out = tool
1413            .invoke(
1414                json!({
1415                    "command": "sleep 10",
1416                    "run_in_background": false,
1417                    "timeout": 50
1418                }),
1419                ToolCtx::none("t"),
1420            )
1421            .await
1422            .expect("force-sync should produce a timed-out result");
1423        let ToolOutcome::Completed(result) = out else {
1424            panic!("expected Completed")
1425        };
1426
1427        let payload: Value = serde_json::from_str(&result.result).unwrap();
1428        assert!(
1429            payload.get("bash_id").is_none(),
1430            "force-sync must never promote"
1431        );
1432        assert_eq!(payload["timed_out"], true);
1433        assert!(!result.success, "timed-out result must not be successful");
1434    }
1435
1436    // (e) Auto path (run_in_background OMITTED) with can_async_resume == false
1437    // also does NOT promote — it runs purely synchronously, exactly like (c),
1438    // even though promotion is now the default. This is the hook-less-path guard
1439    // (issue #84, phase 2d): a loop that can't suspend+resume a background shell
1440    // (schedule path, agent-core loop) must never orphan one via auto-promotion.
1441    // `execute()` builds `ToolExecutionContext::none`, whose can_async_resume is
1442    // false, so the None dispatch arm passes promote_after_ms = None.
1443    #[cfg(not(target_os = "windows"))]
1444    #[tokio::test]
1445    async fn auto_path_does_not_promote_when_not_resume_capable() {
1446        prime_test_command_environment();
1447        let tool = BashTool::new();
1448
1449        let out = tool
1450            .invoke(
1451                json!({
1452                    "command": "sleep 10",
1453                    "timeout": 50
1454                }),
1455                ToolCtx::none("t"),
1456            )
1457            .await
1458            .expect("non-resume-capable auto path should produce a result");
1459        let ToolOutcome::Completed(result) = out else {
1460            panic!("expected Completed")
1461        };
1462
1463        let payload: Value = serde_json::from_str(&result.result).unwrap();
1464        assert!(
1465            payload.get("bash_id").is_none(),
1466            "auto path must not promote when can_async_resume is false"
1467        );
1468        assert_eq!(payload["timed_out"], true);
1469        assert!(
1470            !result.success,
1471            "a timed-out command must not report success"
1472        );
1473    }
1474
1475    // (d) adopt_running_child preserves already-captured output: seed lines
1476    // appear in subsequent read_output_since calls.
1477    #[cfg(not(target_os = "windows"))]
1478    #[tokio::test]
1479    async fn adopt_running_child_preserves_seeded_output() {
1480        prime_test_command_environment();
1481        let shell = bamboo_infrastructure::process::preferred_bash_shell();
1482        let mut cmd = tokio::process::Command::new(&shell.program);
1483        bamboo_infrastructure::process::hide_window_for_tokio_command(&mut cmd);
1484        cmd.arg(shell.arg)
1485            .arg("echo seeded-line-1; echo seeded-line-2; sleep 5")
1486            .stdin(std::process::Stdio::null())
1487            .stdout(std::process::Stdio::piped())
1488            .stderr(std::process::Stdio::piped())
1489            .kill_on_drop(true);
1490        let mut child = cmd.spawn().expect("spawn child");
1491        let stdout_reader = tokio::io::BufReader::new(child.stdout.take().unwrap());
1492        let stderr_reader = tokio::io::BufReader::new(child.stderr.take().unwrap());
1493
1494        // Let the echo output land in the pipe buffer before adoption.
1495        sleep(Duration::from_millis(200)).await;
1496
1497        let session = super::bash_runtime::adopt_running_child(
1498            child,
1499            stdout_reader,
1500            stderr_reader,
1501            vec!["seeded-line-1".to_string(), "seeded-line-2".to_string()],
1502            vec![],
1503            "echo seeded-line-1; echo seeded-line-2; sleep 5",
1504            Some("session_seed_test".to_string()),
1505            test_environment_diagnostics(),
1506            None,
1507            None,
1508        )
1509        .await
1510        .expect("adopt should succeed");
1511
1512        let (lines, _cursor, _dropped) = session.read_output_since(0, None).await;
1513        assert!(
1514            lines.iter().any(|l| l.contains("seeded-line-1")),
1515            "seeded line 1 must be present, got {lines:?}"
1516        );
1517        assert!(
1518            lines.iter().any(|l| l.contains("seeded-line-2")),
1519            "seeded line 2 must be present, got {lines:?}"
1520        );
1521
1522        let _ = session.kill().await;
1523        let _ = super::bash_runtime::remove_shell(&session.id);
1524    }
1525
1526    // ── Phase 2b follow-up: loop-facing completion push (BashCompletionSink) ──
1527
1528    /// A `BashCompletionSink` that records every completion it receives, so a
1529    /// test can assert the producer pushed the right info + output tail.
1530    #[derive(Clone, Default)]
1531    struct RecordingSink {
1532        received: std::sync::Arc<std::sync::Mutex<Vec<bamboo_agent_core::BashCompletionInfo>>>,
1533    }
1534
1535    impl bamboo_agent_core::BashCompletionSink for RecordingSink {
1536        fn on_bash_completed(&self, info: bamboo_agent_core::BashCompletionInfo) {
1537            self.received.lock().unwrap().push(info);
1538        }
1539    }
1540
1541    async fn wait_for_sink(
1542        recorder: &RecordingSink,
1543        what: &str,
1544    ) -> bamboo_agent_core::BashCompletionInfo {
1545        let started = Instant::now();
1546        loop {
1547            if let Some(info) = recorder.received.lock().unwrap().first().cloned() {
1548                return info;
1549            }
1550            if started.elapsed() > Duration::from_secs(5) {
1551                panic!("completion sink was not called for {what}");
1552            }
1553            sleep(Duration::from_millis(50)).await;
1554        }
1555    }
1556
1557    #[tokio::test]
1558    async fn background_completion_pushes_to_sink_with_output_tail() {
1559        prime_test_command_environment();
1560        let recorder = RecordingSink::default();
1561        let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1562            std::sync::Arc::new(recorder.clone());
1563
1564        let shell = super::bash_runtime::spawn_background(
1565            "echo hello-sink",
1566            None,
1567            None,
1568            Some("sess-sink".to_string()),
1569            false,
1570            Some(sink),
1571        )
1572        .await
1573        .expect("spawn");
1574
1575        let info = wait_for_sink(&recorder, "echo").await;
1576        assert_eq!(info.session_id, "sess-sink");
1577        assert_eq!(info.bash_id, shell.id);
1578        assert_eq!(info.status, "completed");
1579        assert_eq!(info.exit_code, Some(0));
1580        assert!(
1581            info.output_tail.contains("hello-sink"),
1582            "output tail should carry the command output, got: {:?}",
1583            info.output_tail
1584        );
1585
1586        let _ = super::bash_runtime::remove_shell(&shell.id);
1587    }
1588
1589    #[tokio::test]
1590    async fn background_completion_carries_nonzero_exit_code() {
1591        prime_test_command_environment();
1592        let recorder = RecordingSink::default();
1593        let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1594            std::sync::Arc::new(recorder.clone());
1595
1596        let shell = super::bash_runtime::spawn_background(
1597            "exit 3",
1598            None,
1599            None,
1600            Some("sess-exit".to_string()),
1601            false,
1602            Some(sink),
1603        )
1604        .await
1605        .expect("spawn");
1606
1607        let info = wait_for_sink(&recorder, "exit 3").await;
1608        assert_eq!(info.status, "completed");
1609        assert_eq!(info.exit_code, Some(3));
1610
1611        let _ = super::bash_runtime::remove_shell(&shell.id);
1612    }
1613
1614    /// A killed background shell pushes to the sink with `status="killed"` — this
1615    /// drives the event-driven completion task's `select!` kill branch (kill_notify
1616    /// → start_kill → wait) all the way through to the loop-facing push, so the
1617    /// owning loop is notified even when the shell was terminated rather than
1618    /// exiting on its own.
1619    #[cfg(not(target_os = "windows"))]
1620    #[tokio::test]
1621    async fn killed_background_shell_pushes_killed_to_sink() {
1622        prime_test_command_environment();
1623        let recorder = RecordingSink::default();
1624        let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1625            std::sync::Arc::new(recorder.clone());
1626
1627        let shell = super::bash_runtime::spawn_background(
1628            "sleep 30",
1629            None,
1630            None,
1631            Some("sess-kill".to_string()),
1632            false,
1633            Some(sink),
1634        )
1635        .await
1636        .expect("spawn");
1637
1638        shell.kill().await.expect("shell should be killable");
1639
1640        let info = wait_for_sink(&recorder, "killed sleep").await;
1641        assert_eq!(info.session_id, "sess-kill");
1642        assert_eq!(info.bash_id, shell.id);
1643        assert_eq!(info.status, "killed");
1644        assert_eq!(info.exit_code, None);
1645
1646        let _ = super::bash_runtime::remove_shell(&shell.id);
1647    }
1648
1649    #[tokio::test]
1650    async fn untagged_shell_does_not_invoke_sink() {
1651        prime_test_command_environment();
1652        let recorder = RecordingSink::default();
1653        let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1654            std::sync::Arc::new(recorder.clone());
1655
1656        // session_id = None → no owning loop to notify → the sink must not fire,
1657        // even though it is wired.
1658        let shell =
1659            super::bash_runtime::spawn_background("true", None, None, None, false, Some(sink))
1660                .await
1661                .expect("spawn");
1662
1663        let started = Instant::now();
1664        while shell.status() == "running" {
1665            if started.elapsed() > Duration::from_secs(5) {
1666                panic!("shell never completed");
1667            }
1668            sleep(Duration::from_millis(50)).await;
1669        }
1670        // Give the poll task's post-exit emit path time to run.
1671        sleep(Duration::from_millis(200)).await;
1672        assert!(
1673            recorder.received.lock().unwrap().is_empty(),
1674            "an untagged (session-less) shell must not push a completion"
1675        );
1676
1677        let _ = super::bash_runtime::remove_shell(&shell.id);
1678    }
1679}