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