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::{AsyncBufRead, AsyncBufReadExt, BufReader};
13use tokio::process::Command;
14use tokio::time::{Duration, Instant};
15
16use super::{bash_runtime, workspace_state};
17use crate::local_task_resources::ManagedBashChild;
18
19const DEFAULT_TIMEOUT_MS: u64 = 120_000;
20const MAX_TIMEOUT_MS: u64 = 600_000;
21const MAX_CAPTURE_BYTES: usize = 512 * 1024;
22
23/// Read one newline-delimited process output chunk without losing bytes that a
24/// cancelled `read_until` already appended to `pending_bytes`.
25///
26/// `read_until` is cancellation-safe because those bytes remain in the caller's
27/// buffer. A subsequent read at EOF can still return `0`, though, so EOF is only
28/// final when that retained buffer is also empty.
29async fn read_process_line<R>(
30    reader: &mut R,
31    pending_bytes: &mut Vec<u8>,
32) -> std::io::Result<Option<String>>
33where
34    R: AsyncBufRead + Unpin,
35{
36    let bytes_read = reader.read_until(b'\n', pending_bytes).await?;
37    if bytes_read == 0 && pending_bytes.is_empty() {
38        return Ok(None);
39    }
40
41    Ok(Some(decode_process_line_lossy(pending_bytes)))
42}
43
44/// Auto-sync promotion threshold (issue #84, phase 2d). Commands started via the
45/// auto path (`run_in_background` omitted) that are still running after this many
46/// milliseconds are promoted to background instead of continuing to block.
47/// Deliberately generous so virtually all interactive commands stay synchronous.
48const PROMOTE_TO_BACKGROUND_AFTER_MS: u64 = 10_000;
49
50#[derive(Debug, Deserialize)]
51struct BashArgs {
52    command: String,
53    #[serde(default)]
54    timeout: Option<u64>,
55    #[serde(default)]
56    description: Option<String>,
57    #[serde(default)]
58    run_in_background: Option<bool>,
59    #[serde(default)]
60    interactive: Option<bool>,
61    #[serde(default)]
62    workdir: Option<String>,
63}
64
65pub struct BashTool;
66
67impl BashTool {
68    pub fn new() -> Self {
69        Self
70    }
71
72    fn effective_timeout_ms(requested: Option<u64>) -> u64 {
73        let value = requested.unwrap_or(DEFAULT_TIMEOUT_MS);
74        value.clamp(1, MAX_TIMEOUT_MS)
75    }
76
77    fn append_capped(buffer: &mut String, line: &str, truncated: &mut bool) {
78        if *truncated {
79            return;
80        }
81        let needed = line.len() + 1;
82        if buffer.len() + needed <= MAX_CAPTURE_BYTES {
83            buffer.push_str(line);
84            buffer.push('\n');
85            return;
86        }
87
88        let remaining = MAX_CAPTURE_BYTES.saturating_sub(buffer.len());
89        if remaining > 0 {
90            let take = remaining.saturating_sub(1);
91            if take > 0 {
92                let mut end = take.min(line.len());
93                while end > 0 && !line.is_char_boundary(end) {
94                    end -= 1;
95                }
96                buffer.push_str(&line[..end]);
97            }
98            if buffer.len() < MAX_CAPTURE_BYTES {
99                buffer.push('\n');
100            }
101        }
102        *truncated = true;
103    }
104
105    /// Append a line to a promotion-seed buffer, draining the oldest entries
106    /// when over the same budget the background registry enforces
107    /// (`bash_runtime::MAX_OUTPUT_LINES`). Keeps a chatty command from ballooning
108    /// memory during the ~10s promotion window (issue #84, phase 2d) — the
109    /// `stdout_buf`/`stderr_buf` capture is byte-capped, but the seed Vecs feed
110    /// the background buffer and must not grow unbounded. Mirrors `push_line`'s
111    /// drain-oldest semantics.
112    fn push_capped_seed_line(buf: &mut Vec<String>, line: String) {
113        buf.push(line);
114        let cap = bash_runtime::MAX_OUTPUT_LINES;
115        if buf.len() > cap {
116            let overflow = buf.len() - cap;
117            buf.drain(0..overflow);
118        }
119    }
120
121    fn python_diagnostics_json(
122        diagnostics: &bamboo_infrastructure::process::PythonDiscoveryDiagnostics,
123        include_full_tried: bool,
124    ) -> Value {
125        let mut python = Map::new();
126        if let Some(configured) = diagnostics.configured.as_ref() {
127            python.insert("configured".to_string(), json!(configured));
128        }
129        if let Some(resolved) = diagnostics.resolved.as_ref() {
130            python.insert("resolved".to_string(), json!(resolved));
131        }
132        if let Some(invocation) = diagnostics.invocation.as_ref() {
133            python.insert("invocation".to_string(), json!(invocation));
134        }
135        if let Some(source) = diagnostics.source.as_ref() {
136            python.insert("source".to_string(), json!(source));
137        }
138        if !diagnostics.tried_preview.is_empty() {
139            python.insert(
140                "tried_preview".to_string(),
141                json!(diagnostics.tried_preview),
142            );
143        }
144        if diagnostics.tried_total > 0 {
145            python.insert("tried_total".to_string(), json!(diagnostics.tried_total));
146            python.insert(
147                "tried_truncated".to_string(),
148                json!(diagnostics.tried_truncated),
149            );
150        }
151        if let Some(hint) = diagnostics.hint.as_ref() {
152            python.insert("hint".to_string(), json!(hint));
153        }
154        if include_full_tried && !diagnostics.tried.is_empty() {
155            python.insert("tried".to_string(), json!(diagnostics.tried));
156        }
157        Value::Object(python)
158    }
159
160    fn environment_json(
161        diagnostics: &bamboo_infrastructure::process::CommandEnvironmentDiagnostics,
162        include_full_python_tried: bool,
163    ) -> Value {
164        let mut environment = Map::new();
165        environment.insert("source".to_string(), json!(diagnostics.source.as_str()));
166        if let Some(import_shell) = diagnostics.import_shell.as_ref() {
167            environment.insert("import_shell".to_string(), json!(import_shell));
168        }
169        if let Some(import_error) = diagnostics.import_error.as_ref() {
170            environment.insert("import_error".to_string(), json!(import_error));
171        }
172        if let Some(path) = diagnostics.path.as_ref() {
173            environment.insert("path".to_string(), json!(path));
174        }
175        if let Some(path_entries) = diagnostics.path_entries {
176            environment.insert("path_entries".to_string(), json!(path_entries));
177        }
178
179        let python = Self::python_diagnostics_json(&diagnostics.python, include_full_python_tried);
180        if python
181            .as_object()
182            .map(|map| !map.is_empty())
183            .unwrap_or(false)
184        {
185            environment.insert("python".to_string(), python);
186        }
187
188        Value::Object(environment)
189    }
190
191    fn resolve_cwd(session_workspace: &Path, workdir: Option<&str>) -> Result<PathBuf, ToolError> {
192        let resolved = match workdir {
193            Some(raw) => {
194                let trimmed = raw.trim();
195                if trimmed.is_empty() {
196                    return Err(ToolError::InvalidArguments(
197                        "'workdir' cannot be empty".to_string(),
198                    ));
199                }
200                let requested = Path::new(trimmed);
201                if requested.is_absolute() {
202                    requested.to_path_buf()
203                } else {
204                    session_workspace.join(requested)
205                }
206            }
207            None => session_workspace.to_path_buf(),
208        };
209
210        let metadata = std::fs::metadata(&resolved).map_err(|error| {
211            ToolError::InvalidArguments(format!(
212                "Invalid workdir '{}': {}",
213                bamboo_config::paths::path_to_display_string(&resolved),
214                error
215            ))
216        })?;
217        if !metadata.is_dir() {
218            return Err(ToolError::InvalidArguments(format!(
219                "workdir must be a directory: {}",
220                bamboo_config::paths::path_to_display_string(&resolved)
221            )));
222        }
223
224        resolved.canonicalize().map_err(|error| {
225            ToolError::Execution(format!(
226                "Failed to canonicalize workdir '{}': {}",
227                bamboo_config::paths::path_to_display_string(&resolved),
228                error
229            ))
230        })
231    }
232
233    async fn prepare_environment() -> PreparedCommandEnvironment {
234        let overrides = bamboo_llm::Config::current_env_vars();
235        build_command_environment(&overrides).await
236    }
237
238    /// Run a command with streaming output (issue #84, phase 2d).
239    ///
240    /// When `promote_after_ms` is `None`, this is pure synchronous foreground:
241    /// blocks until the command completes or the timeout fires — byte-identical
242    /// to the pre-2d `run_foreground` (same streaming, capture, exit code, result).
243    ///
244    /// When `promote_after_ms` is `Some(ms)`, the command starts foreground but
245    /// is auto-promoted to background if still running after `ms` milliseconds.
246    /// Fast commands that finish before the promotion deadline never reach the
247    /// promotion branch, so their behavior is unchanged. Only the promotion
248    /// deadline (not the timeout) triggers the hand-off: a timeout with
249    /// `ms >= timeout_ms` kills the child exactly as before.
250    async fn run_streaming_command(
251        &self,
252        command: &str,
253        timeout_ms: u64,
254        promote_after_ms: Option<u64>,
255        cwd: &Path,
256        ctx: ToolCtx,
257    ) -> Result<ToolResult, ToolError> {
258        let shell = preferred_bash_shell();
259        trace_windows_command(
260            "agent.bash.foreground",
261            &shell.program,
262            [shell.arg, command],
263        );
264        if windows_command_trace_enabled() {
265            let rendered = render_command_line(&shell.program, [shell.arg, command]);
266            ctx.emit_tool_token(format!("[windows-cmd-trace] {rendered}\n"))
267                .await;
268        }
269
270        let prepared_env = Self::prepare_environment().await;
271
272        let mut cmd = Command::new(&shell.program);
273        hide_window_for_tokio_command(&mut cmd);
274        cmd.current_dir(cwd);
275        prepared_env.apply_to_tokio_command(&mut cmd);
276        cmd.arg(shell.arg)
277            .arg(command)
278            .stdin(Stdio::null())
279            .stdout(Stdio::piped())
280            .stderr(Stdio::piped())
281            .kill_on_drop(true);
282
283        let mut child = ManagedBashChild::spawn(&mut cmd)
284            .map_err(|e| ToolError::Execution(format!("Failed to execute command: {}", e)))?;
285
286        let stdout = child
287            .take_stdout()
288            .ok_or_else(|| ToolError::Execution("Failed to capture stdout".to_string()))?;
289        let stderr = child
290            .take_stderr()
291            .ok_or_else(|| ToolError::Execution("Failed to capture stderr".to_string()))?;
292
293        let mut stdout_reader = BufReader::new(stdout);
294        let mut stderr_reader = BufReader::new(stderr);
295        let mut stdout_line_bytes = Vec::new();
296        let mut stderr_line_bytes = Vec::new();
297
298        let mut stdout_buf = String::new();
299        let mut stderr_buf = String::new();
300        // Individual decoded lines collected for promotion seeding. Only
301        // populated when promotion is enabled — a cheap Vec::push per line that
302        // is never touched on the pure-foreground path.
303        let mut stdout_lines: Vec<String> = Vec::new();
304        let mut stderr_lines: Vec<String> = Vec::new();
305        let mut stdout_truncated = false;
306        let mut stderr_truncated = false;
307        let mut stdout_done = false;
308        let mut stderr_done = false;
309
310        // Effective deadline: when promotion is enabled, the earlier of the
311        // promotion threshold and the timeout; otherwise just the timeout.
312        let timeout_deadline = Instant::now() + Duration::from_millis(timeout_ms);
313        let effective_deadline = match promote_after_ms {
314            Some(promote_ms) => {
315                (Instant::now() + Duration::from_millis(promote_ms)).min(timeout_deadline)
316            }
317            None => timeout_deadline,
318        };
319
320        while !(stdout_done && stderr_done) {
321            if Instant::now() >= effective_deadline {
322                break;
323            }
324
325            let remaining = effective_deadline.saturating_duration_since(Instant::now());
326            tokio::select! {
327                line = read_process_line(&mut stdout_reader, &mut stdout_line_bytes), if !stdout_done => {
328                    match line {
329                        Ok(None) => stdout_done = true,
330                        Ok(Some(line)) => {
331                            Self::append_capped(&mut stdout_buf, &line, &mut stdout_truncated);
332                            if promote_after_ms.is_some() {
333                                Self::push_capped_seed_line(&mut stdout_lines, line.clone());
334                            }
335                            ctx.emit_tool_token(format!("{}\n", line)).await;
336                        }
337                        Err(e) => {
338                            return Err(ToolError::Execution(format!("Failed reading stdout: {}", e)));
339                        }
340                    }
341                }
342                line = read_process_line(&mut stderr_reader, &mut stderr_line_bytes), if !stderr_done => {
343                    match line {
344                        Ok(None) => stderr_done = true,
345                        Ok(Some(line)) => {
346                            Self::append_capped(&mut stderr_buf, &line, &mut stderr_truncated);
347                            if promote_after_ms.is_some() {
348                                Self::push_capped_seed_line(&mut stderr_lines, line.clone());
349                            }
350                            ctx.emit_tool_token(format!("{}\n", line)).await;
351                        }
352                        Err(e) => {
353                            return Err(ToolError::Execution(format!("Failed reading stderr: {}", e)));
354                        }
355                    }
356                }
357                _ = tokio::time::sleep(remaining) => {
358                    break;
359                }
360            }
361        }
362
363        let streams_closed = stdout_done && stderr_done;
364
365        // A promotion fires only when promotion is enabled AND the promotion
366        // threshold is strictly less than the timeout. When promotion is
367        // disabled or timeout <= promote, the deadline firing is a timeout.
368        let promotion_fired =
369            !streams_closed && promote_after_ms.is_some() && promote_after_ms.unwrap() < timeout_ms;
370
371        if streams_closed {
372            // Command completed normally — identical to the pre-2d foreground path.
373            let status = child
374                .wait()
375                .await
376                .map_err(|e| ToolError::Execution(format!("Failed waiting command: {}", e)))?;
377            let exit_code = status.code();
378            let success = exit_code.unwrap_or(-1) == 0;
379            let cwd_display = bamboo_config::paths::path_to_display_string(cwd);
380            let environment = Self::environment_json(&prepared_env.diagnostics, !success);
381
382            return Ok(ToolResult {
383                success,
384                result: json!({
385                    "command": command,
386                    "cwd": cwd_display,
387                    "stdout": stdout_buf,
388                    "stderr": stderr_buf,
389                    "exit_code": exit_code,
390                    "timed_out": false,
391                    "stdout_truncated": stdout_truncated,
392                    "stderr_truncated": stderr_truncated,
393                    "environment": environment,
394                })
395                .to_string(),
396                display_preference: Some("Collapsible".to_string()),
397                images: Vec::new(),
398            });
399        }
400
401        if promotion_fired {
402            // Promotion deadline fired while the child is still running —
403            // hand off the live child to the background registry. Do NOT kill:
404            // pump tasks continue draining and the completion poll emits
405            // BashCompleted when the child exits (issue #84, phase 2d).
406            //
407            // Flush any partial line bytes left by a cancelled `read_until` —
408            // they represent real bytes consumed from the pipe that must not be
409            // lost across the hand-off.
410            if !stdout_line_bytes.is_empty() {
411                let partial = decode_process_line_lossy(&mut stdout_line_bytes);
412                if !partial.is_empty() {
413                    Self::push_capped_seed_line(&mut stdout_lines, partial);
414                }
415            }
416            if !stderr_line_bytes.is_empty() {
417                let partial = decode_process_line_lossy(&mut stderr_line_bytes);
418                if !partial.is_empty() {
419                    Self::push_capped_seed_line(&mut stderr_lines, partial);
420                }
421            }
422
423            let session = bash_runtime::adopt_managed_child(
424                child,
425                stdout_reader,
426                stderr_reader,
427                stdout_lines,
428                stderr_lines,
429                command,
430                ctx.session_id().map(str::to_string),
431                prepared_env.diagnostics.clone(),
432                ctx.cloned_sender(),
433                ctx.cloned_bash_completion_sink(),
434            )
435            .await
436            .map_err(ToolError::Execution)?;
437
438            return Ok(ToolResult {
439                success: true,
440                result: json!({
441                    "bash_id": session.id,
442                    "command": session.command,
443                    "status": "running",
444                    "cwd": bamboo_config::paths::path_to_display_string(cwd),
445                    "environment": Self::environment_json(&session.environment, false),
446                })
447                .to_string(),
448                display_preference: Some("Collapsible".to_string()),
449                images: Vec::new(),
450            });
451        }
452
453        // Timeout fired (promotion disabled or timeout <= promote threshold).
454        let _ = child.kill().await;
455        let cwd_display = bamboo_config::paths::path_to_display_string(cwd);
456        let environment = Self::environment_json(&prepared_env.diagnostics, true);
457
458        Ok(ToolResult {
459            success: false,
460            result: json!({
461                "command": command,
462                "cwd": cwd_display,
463                "stdout": stdout_buf,
464                "stderr": stderr_buf,
465                "exit_code": serde_json::Value::Null,
466                "timed_out": true,
467                "stdout_truncated": stdout_truncated,
468                "stderr_truncated": stderr_truncated,
469                "environment": environment,
470            })
471            .to_string(),
472            display_preference: Some("Collapsible".to_string()),
473            images: Vec::new(),
474        })
475    }
476}
477
478impl Default for BashTool {
479    fn default() -> Self {
480        Self::new()
481    }
482}
483
484#[async_trait]
485impl Tool for BashTool {
486    fn name(&self) -> &str {
487        "Bash"
488    }
489
490    fn description(&self) -> &str {
491        "Execute shell commands with streaming output (supports background mode). \
492         By default (run_in_background omitted), commands run synchronously but are \
493         auto-promoted to background if they run longer than ~10s — fast commands \
494         behave exactly as foreground. Set run_in_background to false to force \
495         synchronous (block until timeout), or true to force immediate background. \
496         Set interactive to true to spawn in the background with a piped stdin so \
497         input can be fed over time via BashInput (interactive implies background; \
498         use it only to answer an interactive prompt). A backgrounded command runs \
499         detached and does NOT block the loop: keep working, and when it finishes \
500         you are automatically notified with a message carrying its exit status and \
501         a tail of its output — you do NOT need to poll. Use BashOutput only when \
502         you want the full output before then; KillShell to stop it early. Default \
503         timeout is 120000ms (max 600000ms); captured stdout/stderr are each \
504         capped at 512KB."
505    }
506
507    fn parameters_schema(&self) -> serde_json::Value {
508        json!({
509            "type": "object",
510            "properties": {
511                "command": {
512                    "type": "string",
513                    "description": "The command to execute"
514                },
515                "timeout": {
516                    "type": "number",
517                    "description": "Optional timeout in milliseconds (default 120000, max 600000)"
518                },
519                "description": {
520                    "type": "string",
521                    "description": "Optional short context label for the command"
522                },
523                "run_in_background": {
524                    "type": "boolean",
525                    "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."
526                },
527                "interactive": {
528                    "type": "boolean",
529                    "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."
530                },
531                "workdir": {
532                    "type": "string",
533                    "description": "Optional working directory. Relative paths are resolved from the session workspace."
534                }
535            },
536            "required": ["command"],
537            "additionalProperties": false
538        })
539    }
540
541    async fn invoke(
542        &self,
543        args: serde_json::Value,
544        ctx: ToolCtx,
545    ) -> Result<ToolOutcome, ToolError> {
546        let parsed: BashArgs = serde_json::from_value(args)
547            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Bash args: {}", e)))?;
548
549        let command = parsed.command.trim();
550        if command.is_empty() {
551            return Err(ToolError::InvalidArguments(
552                "'command' cannot be empty".to_string(),
553            ));
554        }
555
556        if !bamboo_agent_core::tools::suspension_allowed()
557            && (parsed.interactive == Some(true) || parsed.run_in_background == Some(true))
558        {
559            return Err(ToolError::Execution(
560                "suspension_disabled: background or interactive Bash is unavailable in this execution".into(),
561            ));
562        }
563        let _ = parsed.description;
564        let timeout_ms = Self::effective_timeout_ms(parsed.timeout);
565        let session_workspace = workspace_state::workspace_or_process_cwd(ctx.session_id());
566        let cwd = Self::resolve_cwd(&session_workspace, parsed.workdir.as_deref())?;
567
568        if parsed.interactive == Some(true) {
569            // Interactive (issue #89): spawn in the background with a piped
570            // stdin so the shell can be fed input over time via BashInput.
571            // interactive:true implies run_in_background — return a bash_id
572            // immediately. The non-interactive paths below keep Stdio::null(),
573            // so default EOF-on-read is byte-for-byte unchanged.
574            let shell = bash_runtime::spawn_background(
575                command,
576                Some(&cwd),
577                ctx.cloned_sender(),
578                ctx.session_id().map(str::to_string),
579                true,
580                ctx.cloned_bash_completion_sink(),
581            )
582            .await
583            .map_err(ToolError::Execution)?;
584
585            if let Some(requested_timeout) = parsed.timeout {
586                let kill_after_ms = Self::effective_timeout_ms(Some(requested_timeout));
587                let shell_clone = shell.clone();
588                tokio::spawn(async move {
589                    tokio::time::sleep(Duration::from_millis(kill_after_ms)).await;
590                    if shell_clone.status() == "running" {
591                        let _ = shell_clone.kill().await;
592                    }
593                });
594            }
595
596            return Ok(ToolOutcome::Completed(ToolResult {
597                success: true,
598                result: json!({
599                    "bash_id": shell.id,
600                    "command": shell.command,
601                    "status": "running",
602                    "interactive": true,
603                    "cwd": bamboo_config::paths::path_to_display_string(&cwd),
604                    "environment": Self::environment_json(&shell.environment, false),
605                })
606                .to_string(),
607                display_preference: Some("Collapsible".to_string()),
608                images: Vec::new(),
609            }));
610        }
611
612        match parsed.run_in_background {
613            Some(true) => {
614                // Force background — spawn immediately (issue #84, phase 1).
615                let shell = bash_runtime::spawn_background(
616                    command,
617                    Some(&cwd),
618                    ctx.cloned_sender(),
619                    ctx.session_id().map(str::to_string),
620                    false,
621                    ctx.cloned_bash_completion_sink(),
622                )
623                .await
624                .map_err(ToolError::Execution)?;
625
626                if let Some(requested_timeout) = parsed.timeout {
627                    let kill_after_ms = Self::effective_timeout_ms(Some(requested_timeout));
628                    let shell_clone = shell.clone();
629                    tokio::spawn(async move {
630                        tokio::time::sleep(Duration::from_millis(kill_after_ms)).await;
631                        if shell_clone.status() == "running" {
632                            let _ = shell_clone.kill().await;
633                        }
634                    });
635                }
636
637                Ok(ToolOutcome::Completed(ToolResult {
638                    success: true,
639                    result: json!({
640                        "bash_id": shell.id,
641                        "command": shell.command,
642                        "status": "running",
643                        "cwd": bamboo_config::paths::path_to_display_string(&cwd),
644                        "environment": Self::environment_json(&shell.environment, false),
645                    })
646                    .to_string(),
647                    display_preference: Some("Collapsible".to_string()),
648                    images: Vec::new(),
649                }))
650            }
651            Some(false) => {
652                // Force synchronous — pure foreground, no promotion (issue #84,
653                // phase 2d). Blocks until the command completes or times out,
654                // exactly like the pre-2d behavior.
655                self.run_streaming_command(command, timeout_ms, None, &cwd, ctx)
656                    .await
657                    .map(ToolOutcome::Completed)
658            }
659            None => {
660                // Auto-sync promotion (issue #84, phase 2d). Runs foreground but
661                // promotes to background if still running after the promotion
662                // threshold (~10s). Fast commands finish synchronously. Promotion
663                // is ONLY enabled when the executing loop can actually suspend
664                // for and self-resume a backgrounded shell (`can_async_resume`):
665                // on hook-less paths (schedule / external-child loops) it stays
666                // purely synchronous so a long command's output is never orphaned.
667                let promote_after_ms =
668                    if ctx.can_async_resume && bamboo_agent_core::tools::suspension_allowed() {
669                        Some(PROMOTE_TO_BACKGROUND_AFTER_MS)
670                    } else {
671                        None
672                    };
673                self.run_streaming_command(command, timeout_ms, promote_after_ms, &cwd, ctx)
674                    .await
675                    .map(ToolOutcome::Completed)
676            }
677        }
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684    use bamboo_agent_core::tools::ToolExecutionSessionFlags;
685    use bamboo_agent_core::{AgentEvent, ToolExecutionContext};
686    use bamboo_infrastructure::process::{
687        CommandEnvironmentDiagnostics, CommandEnvironmentSource, PythonDiscoveryDiagnostics,
688    };
689    use bamboo_infrastructure::test_support::{
690        override_command_environment, CommandEnvironmentOverrideGuard,
691    };
692    use serde_json::Value;
693    use std::collections::HashMap;
694    use tokio::io::{duplex, AsyncWriteExt};
695    use tokio::sync::mpsc;
696    use tokio::time::{sleep, Duration, Instant};
697
698    #[cfg(target_os = "windows")]
699    fn mixed_output_command() -> &'static str {
700        "echo out && echo err 1>&2"
701    }
702
703    #[cfg(not(target_os = "windows"))]
704    fn mixed_output_command() -> &'static str {
705        "printf 'out\\n'; printf 'err\\n' 1>&2"
706    }
707
708    #[cfg(target_os = "windows")]
709    fn invalid_utf8_stderr_command() -> String {
710        let shell = bamboo_infrastructure::process::preferred_bash_shell();
711        if shell.arg == "-lc" {
712            "printf '\\377\\n' 1>&2".to_string()
713        } else {
714            "powershell -NoProfile -Command \"$bytes = [byte[]](0xFF,0x0A); [Console]::OpenStandardError().Write($bytes,0,$bytes.Length)\"".to_string()
715        }
716    }
717
718    #[cfg(not(target_os = "windows"))]
719    fn invalid_utf8_stderr_command() -> String {
720        "printf '\\377\\n' 1>&2".to_string()
721    }
722
723    fn test_environment_diagnostics() -> CommandEnvironmentDiagnostics {
724        CommandEnvironmentDiagnostics {
725            source: CommandEnvironmentSource::InheritedProcess,
726            import_shell: None,
727            import_error: Some("test-import-disabled".to_string()),
728            path: Some("/usr/bin:/bin".to_string()),
729            path_entries: Some(2),
730            python: PythonDiscoveryDiagnostics {
731                configured: Some("python3".to_string()),
732                resolved: Some("/usr/bin/python3".to_string()),
733                invocation: Some("/usr/bin/python3".to_string()),
734                source: Some("path".to_string()),
735                tried: vec!["python3".to_string(), "python".to_string()],
736                tried_preview: vec!["python3".to_string(), "python".to_string()],
737                tried_total: 2,
738                tried_truncated: false,
739                hint: None,
740            },
741        }
742    }
743
744    fn test_command_environment() -> CommandEnvironmentOverrideGuard {
745        override_command_environment(
746            HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]),
747            test_environment_diagnostics(),
748        )
749    }
750
751    async fn assert_canceled_partial_line_is_flushed_at_eof(expected: &str) {
752        let (mut writer, reader) = duplex(64);
753        writer.write_all(expected.as_bytes()).await.unwrap();
754        let mut reader = BufReader::new(reader);
755        let mut pending_bytes = Vec::new();
756
757        // Poll the read first so it consumes the available non-newline bytes,
758        // then cancel it through the already-ready competing branch while the
759        // writer remains open. This deterministically models the stdout/stderr
760        // select interleaving that exposed #1106.
761        tokio::select! {
762            biased;
763            result = read_process_line(&mut reader, &mut pending_bytes) => {
764                panic!("partial read unexpectedly completed: {result:?}");
765            }
766            _ = std::future::ready(()) => {}
767        }
768
769        assert_eq!(pending_bytes, expected.as_bytes());
770        drop(writer);
771        assert_eq!(
772            read_process_line(&mut reader, &mut pending_bytes)
773                .await
774                .unwrap(),
775            Some(expected.to_string())
776        );
777        assert!(pending_bytes.is_empty());
778        assert_eq!(
779            read_process_line(&mut reader, &mut pending_bytes)
780                .await
781                .unwrap(),
782            None
783        );
784    }
785
786    #[tokio::test]
787    async fn canceled_partial_stdout_is_flushed_when_the_next_read_reaches_eof() {
788        assert_canceled_partial_line_is_flushed_at_eof("stdout-without-newline").await;
789    }
790
791    #[tokio::test]
792    async fn canceled_partial_stderr_is_flushed_when_the_next_read_reaches_eof() {
793        assert_canceled_partial_line_is_flushed_at_eof("stderr-without-newline").await;
794    }
795
796    #[tokio::test]
797    async fn bash_foreground_returns_stdout_stderr_and_streams_tokens() {
798        let _command_environment = test_command_environment();
799        let tool = BashTool::new();
800        let (tx, mut rx) = mpsc::channel(32);
801
802        let out = tool
803            .invoke(
804                json!({
805                    "command": mixed_output_command()
806                }),
807                ToolExecutionContext {
808                    executing_supervisor: None,
809                    session_id: Some("session_1"),
810                    root_session_id: None,
811                    tool_call_id: "call_1",
812                    event_tx: Some(&tx),
813                    available_tool_schemas: None,
814                    bypass_permissions: false,
815                    auto_approve_permissions: false,
816                    plan_read_only: false,
817                    can_async_resume: false,
818                    bash_completion_sink: None,
819                    pre_parsed_args: None,
820                }
821                .to_tool_ctx(),
822            )
823            .await
824            .unwrap();
825        let ToolOutcome::Completed(result) = out else {
826            panic!("expected Completed")
827        };
828
829        assert!(result.success);
830
831        let payload: Value = serde_json::from_str(&result.result).unwrap();
832        assert_eq!(payload["timed_out"], false);
833        assert_eq!(payload["exit_code"], 0);
834        assert!(payload["stdout"]
835            .as_str()
836            .unwrap_or_default()
837            .contains("out"));
838        assert!(payload["stderr"]
839            .as_str()
840            .unwrap_or_default()
841            .contains("err"));
842        assert_eq!(payload["environment"]["source"], "process_env");
843        assert_eq!(
844            payload["environment"]["import_error"],
845            "test-import-disabled"
846        );
847        assert_eq!(
848            payload["environment"]["python"]["resolved"],
849            "/usr/bin/python3"
850        );
851        assert_eq!(
852            payload["environment"]["python"]["invocation"],
853            "/usr/bin/python3"
854        );
855        assert_eq!(payload["environment"]["python"]["source"], "path");
856        assert_eq!(
857            payload["environment"]["python"]["tried_preview"][0],
858            "python3"
859        );
860        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
861        assert!(payload["environment"]["python"].get("tried").is_none());
862
863        let mut streamed = Vec::new();
864        while let Ok(event) = rx.try_recv() {
865            if let AgentEvent::ToolToken { content, .. } = event {
866                streamed.push(content);
867            }
868        }
869
870        assert!(streamed.iter().any(|line| line.contains("out")));
871        assert!(streamed.iter().any(|line| line.contains("err")));
872    }
873
874    #[cfg(not(target_os = "windows"))]
875    #[tokio::test]
876    async fn bash_foreground_preserves_stdout_and_stderr_without_trailing_newlines() {
877        let _command_environment = test_command_environment();
878        let tool = BashTool::new();
879        let out = tool
880            .invoke(
881                json!({
882                    "command": "printf 'stdout-without-newline'; printf 'stderr-without-newline' 1>&2",
883                    "run_in_background": false
884                }),
885                ToolCtx::none("t"),
886            )
887            .await
888            .unwrap();
889        let ToolOutcome::Completed(result) = out else {
890            panic!("expected Completed")
891        };
892
893        assert!(result.success);
894        let payload: Value = serde_json::from_str(&result.result).unwrap();
895        assert_eq!(payload["exit_code"], 0);
896        assert_eq!(payload["timed_out"], false);
897        assert_eq!(payload["stdout"], "stdout-without-newline\n");
898        assert_eq!(payload["stderr"], "stderr-without-newline\n");
899        assert_eq!(payload["stdout_truncated"], false);
900        assert_eq!(payload["stderr_truncated"], false);
901    }
902
903    #[tokio::test]
904    async fn bash_foreground_tolerates_invalid_utf8_stderr() {
905        let _command_environment = test_command_environment();
906        let tool = BashTool::new();
907        let result = tool
908            .invoke(
909                json!({
910                    "command": invalid_utf8_stderr_command()
911                }),
912                ToolCtx::none("t"),
913            )
914            .await;
915
916        assert!(result.is_ok(), "invalid UTF-8 stderr should not fail");
917        let ToolOutcome::Completed(result) = result.unwrap() else {
918            panic!("expected Completed")
919        };
920        let payload: Value = serde_json::from_str(&result.result).unwrap();
921        let stderr = payload["stderr"].as_str().unwrap_or_default();
922        assert!(!stderr.is_empty());
923    }
924
925    #[cfg(not(target_os = "windows"))]
926    #[tokio::test]
927    async fn bash_foreground_failure_includes_full_python_tried_list() {
928        let _command_environment = test_command_environment();
929        let tool = BashTool::new();
930        let out = tool
931            .invoke(
932                json!({
933                    "command": "false"
934                }),
935                ToolCtx::none("t"),
936            )
937            .await
938            .unwrap();
939        let ToolOutcome::Completed(result) = out else {
940            panic!("expected Completed")
941        };
942
943        assert!(!result.success);
944        let payload: Value = serde_json::from_str(&result.result).unwrap();
945        assert_eq!(payload["exit_code"], 1);
946        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
947        assert_eq!(payload["environment"]["python"]["tried"][0], "python3");
948    }
949
950    #[cfg(not(target_os = "windows"))]
951    #[tokio::test]
952    async fn bash_foreground_sets_stdout_truncated_when_output_exceeds_cap() {
953        let _command_environment = test_command_environment();
954        let tool = BashTool::new();
955        let out = tool
956            .invoke(
957                json!({
958                    "command": "i=0; while [ $i -lt 70000 ]; do printf 'aaaaaaaaaa'; i=$((i+1)); done; printf '\\n'"
959                }),
960                ToolCtx::none("t"),
961            )
962            .await
963            .unwrap();
964        let ToolOutcome::Completed(result) = out else {
965            panic!("expected Completed")
966        };
967
968        let payload: Value = serde_json::from_str(&result.result).unwrap();
969        assert_eq!(payload["timed_out"], false);
970        assert_eq!(payload["stdout_truncated"], true);
971    }
972
973    #[cfg(not(target_os = "windows"))]
974    #[tokio::test]
975    async fn bash_background_honors_explicit_timeout() {
976        let _command_environment = test_command_environment();
977        let tool = BashTool::new();
978        let out = tool
979            .invoke(
980                json!({
981                    "command": "sleep 2",
982                    "run_in_background": true,
983                    "timeout": 50
984                }),
985                ToolCtx::none("t"),
986            )
987            .await
988            .unwrap();
989        let ToolOutcome::Completed(result) = out else {
990            panic!("expected Completed")
991        };
992        let payload: Value = serde_json::from_str(&result.result).unwrap();
993        assert_eq!(payload["environment"]["source"], "process_env");
994        assert_eq!(
995            payload["environment"]["python"]["resolved"],
996            "/usr/bin/python3"
997        );
998        assert_eq!(
999            payload["environment"]["python"]["invocation"],
1000            "/usr/bin/python3"
1001        );
1002        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
1003        assert!(payload["environment"]["python"].get("tried").is_none());
1004        let shell_id = payload["bash_id"].as_str().unwrap().to_string();
1005
1006        let started = Instant::now();
1007        loop {
1008            let shell = super::bash_runtime::get_shell(&shell_id).unwrap();
1009            if shell.status() == "completed" {
1010                break;
1011            }
1012            if started.elapsed() > Duration::from_secs(2) {
1013                panic!("background shell did not stop after timeout");
1014            }
1015            sleep(Duration::from_millis(25)).await;
1016        }
1017    }
1018
1019    /// A short background command must emit a `BashCompleted` signal carrying the
1020    /// session's `bash_id` and an exit code of `Some(0)` (issue #84, phase 1).
1021    #[cfg(not(target_os = "windows"))]
1022    #[tokio::test]
1023    async fn bash_background_emits_completion_event_with_exit_code() {
1024        let _command_environment = test_command_environment();
1025        let (tx, mut rx) = mpsc::channel(8);
1026        let shell =
1027            super::bash_runtime::spawn_background("true", None, Some(tx), None, false, None)
1028                .await
1029                .expect("background shell should spawn");
1030        let expected_id = shell.id.clone();
1031
1032        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1033            .await
1034            .expect("timed out waiting for BashCompleted event")
1035            .expect("event channel closed before BashCompleted");
1036
1037        match event {
1038            AgentEvent::BashCompleted {
1039                bash_id,
1040                command,
1041                exit_code,
1042                status,
1043            } => {
1044                assert_eq!(bash_id, expected_id);
1045                assert_eq!(command, "true");
1046                assert_eq!(exit_code, Some(0));
1047                assert_eq!(status, "completed");
1048            }
1049            other => panic!("expected BashCompleted, got {other:?}"),
1050        }
1051    }
1052
1053    /// A failing background command still reports `status="completed"` with its
1054    /// non-zero exit code — a non-zero exit is a normal completion, not a kill.
1055    #[cfg(not(target_os = "windows"))]
1056    #[tokio::test]
1057    async fn bash_background_emits_completion_event_for_failing_command() {
1058        let _command_environment = test_command_environment();
1059        let (tx, mut rx) = mpsc::channel(8);
1060        let shell =
1061            super::bash_runtime::spawn_background("false", None, Some(tx), None, false, None)
1062                .await
1063                .expect("background shell should spawn");
1064        let expected_id = shell.id.clone();
1065
1066        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1067            .await
1068            .expect("timed out waiting for BashCompleted event")
1069            .expect("event channel closed before BashCompleted");
1070
1071        match event {
1072            AgentEvent::BashCompleted {
1073                bash_id,
1074                exit_code,
1075                status,
1076                ..
1077            } => {
1078                assert_eq!(bash_id, expected_id);
1079                assert_eq!(exit_code, Some(1));
1080                assert_eq!(status, "completed");
1081            }
1082            other => panic!("expected BashCompleted, got {other:?}"),
1083        }
1084    }
1085
1086    /// A killed background command must report `status="killed"` with
1087    /// `exit_code=None` (no numeric code for signal termination on Unix).
1088    #[cfg(not(target_os = "windows"))]
1089    #[tokio::test]
1090    async fn bash_background_emits_killed_when_shell_is_killed() {
1091        let _command_environment = test_command_environment();
1092        let (tx, mut rx) = mpsc::channel(8);
1093        let shell =
1094            super::bash_runtime::spawn_background("sleep 30", None, Some(tx), None, false, None)
1095                .await
1096                .expect("background shell should spawn");
1097        let expected_id = shell.id.clone();
1098
1099        shell.kill().await.expect("shell should be killable");
1100
1101        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1102            .await
1103            .expect("timed out waiting for BashCompleted event")
1104            .expect("event channel closed before BashCompleted");
1105
1106        match event {
1107            AgentEvent::BashCompleted {
1108                bash_id,
1109                exit_code,
1110                status,
1111                ..
1112            } => {
1113                assert_eq!(bash_id, expected_id);
1114                assert_eq!(exit_code, None);
1115                assert_eq!(status, "killed");
1116            }
1117            other => panic!("expected BashCompleted, got {other:?}"),
1118        }
1119    }
1120
1121    /// With no event sender, the poll task must skip the emit entirely and still
1122    /// flip the shell to "completed" (issue #84, phase 1).
1123    #[cfg(not(target_os = "windows"))]
1124    #[tokio::test]
1125    async fn bash_background_without_sender_still_completes() {
1126        let _command_environment = test_command_environment();
1127        let shell = super::bash_runtime::spawn_background("true", None, None, None, false, None)
1128            .await
1129            .expect("background shell should spawn");
1130
1131        let started = Instant::now();
1132        loop {
1133            if shell.status() == "completed" {
1134                break;
1135            }
1136            if started.elapsed() > Duration::from_secs(3) {
1137                panic!("shell never reached completed without a sender");
1138            }
1139            sleep(Duration::from_millis(25)).await;
1140        }
1141    }
1142
1143    /// A saturated event channel must not hang or panic the poll task: the
1144    /// completion send hits the 500ms bounded-timeout path, logs a `warn!`, and
1145    /// the shell still completes. The signal is dropped (observable), not lost
1146    /// silently. The channel is kept full past the timeout window so the send is
1147    /// guaranteed to time out rather than succeed when a slot is freed.
1148    #[cfg(not(target_os = "windows"))]
1149    #[tokio::test]
1150    async fn bash_background_drops_completion_when_channel_saturated() {
1151        let _command_environment = test_command_environment();
1152        // Capacity-1 channel pre-filled so the single slot is occupied.
1153        let (tx, mut rx) = mpsc::channel::<AgentEvent>(1);
1154        tx.try_send(AgentEvent::Token {
1155            content: "occupy".into(),
1156        })
1157        .expect("prefill channel slot");
1158
1159        let shell =
1160            super::bash_runtime::spawn_background("true", None, Some(tx), None, false, None)
1161                .await
1162                .expect("background shell should spawn");
1163
1164        // Wait past the 500ms bounded-send window so the dropped BashCompleted
1165        // has been observed and the poll task has moved on.
1166        sleep(Duration::from_millis(650)).await;
1167
1168        // The only event ever delivered is the pre-filled token; BashCompleted
1169        // was dropped (saturated channel) and never enqueued.
1170        let only = rx
1171            .recv()
1172            .await
1173            .expect("prefilled token should still be present");
1174        assert!(
1175            matches!(only, AgentEvent::Token { .. }),
1176            "expected only the pre-filled token, got {only:?}"
1177        );
1178        assert!(
1179            tokio::time::timeout(Duration::from_millis(50), rx.recv())
1180                .await
1181                .is_err(),
1182            "no BashCompleted should be delivered after a saturation drop"
1183        );
1184        assert_eq!(
1185            shell.status(),
1186            "completed",
1187            "shell must still reach completed after a dropped signal"
1188        );
1189    }
1190
1191    /// Drives the real tool dispatch path: `BashTool::execute_with_context`
1192    /// with `run_in_background=true` and a context built via `for_dispatch`
1193    /// carrying an `event_tx`, so the signal flows through `ctx.cloned_sender()`
1194    /// (the production wiring), not just `spawn_background` directly.
1195    #[cfg(not(target_os = "windows"))]
1196    #[tokio::test]
1197    async fn bash_tool_background_dispatch_emits_completion_event() {
1198        let _command_environment = test_command_environment();
1199        let tool = BashTool::new();
1200        let (tx, mut rx) = mpsc::channel(8);
1201        let ctx = ToolExecutionContext::for_dispatch(
1202            "session_84",
1203            "session_84",
1204            "call_84",
1205            &tx,
1206            &[],
1207            ToolExecutionSessionFlags::default(),
1208            true,
1209            None,
1210            None,
1211        );
1212
1213        let out = tool
1214            .invoke(
1215                json!({ "command": "true", "run_in_background": true }),
1216                ctx.to_tool_ctx(),
1217            )
1218            .await
1219            .expect("background dispatch should succeed");
1220        let ToolOutcome::Completed(result) = out else {
1221            panic!("expected Completed")
1222        };
1223        assert!(result.success);
1224
1225        let payload: Value = serde_json::from_str(&result.result).unwrap();
1226        let bash_id = payload["bash_id"].as_str().unwrap().to_string();
1227        assert_eq!(payload["status"], "running");
1228
1229        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1230            .await
1231            .expect("timed out waiting for BashCompleted event")
1232            .expect("event channel closed before BashCompleted");
1233
1234        match event {
1235            AgentEvent::BashCompleted {
1236                bash_id: id,
1237                exit_code,
1238                status,
1239                ..
1240            } => {
1241                assert_eq!(id, bash_id);
1242                assert_eq!(exit_code, Some(0));
1243                assert_eq!(status, "completed");
1244            }
1245            other => panic!("expected BashCompleted, got {other:?}"),
1246        }
1247    }
1248
1249    #[tokio::test]
1250    async fn bash_resolves_relative_workdir_from_session_workspace() {
1251        let _command_environment = test_command_environment();
1252        let tool = BashTool::new();
1253        let dir = tempfile::tempdir().unwrap();
1254        let base = dir.path().join("base");
1255        let nested = base.join("nested");
1256        tokio::fs::create_dir_all(&nested).await.unwrap();
1257
1258        let session_id = format!("session_{}", uuid::Uuid::new_v4());
1259        super::workspace_state::set_workspace(&session_id, base.canonicalize().unwrap());
1260
1261        let out = tool
1262            .invoke(
1263                json!({
1264                    "command": "pwd",
1265                    "workdir": "nested"
1266                }),
1267                ToolExecutionContext {
1268                    executing_supervisor: None,
1269                    session_id: Some(&session_id),
1270                    root_session_id: None,
1271                    tool_call_id: "call_1",
1272                    event_tx: None,
1273                    available_tool_schemas: None,
1274                    bypass_permissions: false,
1275                    auto_approve_permissions: false,
1276                    plan_read_only: false,
1277                    can_async_resume: false,
1278                    bash_completion_sink: None,
1279                    pre_parsed_args: None,
1280                }
1281                .to_tool_ctx(),
1282            )
1283            .await
1284            .unwrap();
1285        let ToolOutcome::Completed(result) = out else {
1286            panic!("expected Completed")
1287        };
1288
1289        let payload: Value = serde_json::from_str(&result.result).unwrap();
1290        let expected =
1291            bamboo_config::paths::path_to_display_string(&nested.canonicalize().unwrap());
1292        assert_eq!(payload["cwd"].as_str().unwrap_or_default(), expected);
1293    }
1294
1295    #[tokio::test]
1296    async fn bash_rejects_workdir_that_is_not_directory() {
1297        let _command_environment = test_command_environment();
1298        let tool = BashTool::new();
1299        let file = tempfile::NamedTempFile::new().unwrap();
1300
1301        let result = tool
1302            .invoke(
1303                json!({
1304                    "command": "echo hello",
1305                    "workdir": file.path()
1306                }),
1307                ToolCtx::none("t"),
1308            )
1309            .await;
1310
1311        assert!(
1312            matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("directory"))
1313        );
1314    }
1315
1316    /// `running_shells_for_session` reports only the running shells tagged with the
1317    /// requested session, excluding completed shells and shells owned by another
1318    /// session (or none). (issue #84, phase 2a.)
1319    #[cfg(not(target_os = "windows"))]
1320    #[tokio::test]
1321    async fn running_shells_for_session_filters_by_session_and_status() {
1322        let _command_environment = test_command_environment();
1323
1324        // Two long-running shells owned by sess-A.
1325        let a1 = super::bash_runtime::spawn_background(
1326            "sleep 30",
1327            None,
1328            None,
1329            Some("sess-A".to_string()),
1330            false,
1331            None,
1332        )
1333        .await
1334        .expect("spawn a1");
1335        let a2 = super::bash_runtime::spawn_background(
1336            "sleep 30",
1337            None,
1338            None,
1339            Some("sess-A".to_string()),
1340            false,
1341            None,
1342        )
1343        .await
1344        .expect("spawn a2");
1345        // One long-running shell owned by sess-B.
1346        let b = super::bash_runtime::spawn_background(
1347            "sleep 30",
1348            None,
1349            None,
1350            Some("sess-B".to_string()),
1351            false,
1352            None,
1353        )
1354        .await
1355        .expect("spawn b");
1356        // An untagged (None) long-running shell.
1357        let untagged =
1358            super::bash_runtime::spawn_background("sleep 30", None, None, None, false, None)
1359                .await
1360                .expect("spawn untagged");
1361        // A sess-A shell that completes immediately — must be excluded once done.
1362        let done = super::bash_runtime::spawn_background(
1363            "true",
1364            None,
1365            None,
1366            Some("sess-A".to_string()),
1367            false,
1368            None,
1369        )
1370        .await
1371        .expect("spawn done");
1372
1373        // Wait for the fast shell to reach "completed" so we exercise the status filter.
1374        let started = Instant::now();
1375        loop {
1376            if done.status() == "completed" {
1377                break;
1378            }
1379            if started.elapsed() > Duration::from_secs(3) {
1380                panic!("sess-A fast shell never completed");
1381            }
1382            sleep(Duration::from_millis(25)).await;
1383        }
1384
1385        // sess-A should report exactly the two long-running shells, order-independent.
1386        let mut running = super::bash_runtime::running_shells_for_session("sess-A");
1387        running.sort();
1388        let mut expected = vec![a1.id.clone(), a2.id.clone()];
1389        expected.sort();
1390        assert_eq!(running, expected);
1391
1392        // sess-B should report exactly its own single running shell.
1393        assert_eq!(
1394            super::bash_runtime::running_shells_for_session("sess-B"),
1395            vec![b.id.clone()]
1396        );
1397
1398        // Cleanup: kill the still-running shells so the GC isn't left holding them,
1399        // and drop the already-completed `done` shell from the process-global registry.
1400        for shell in [a1, a2, b, untagged] {
1401            let _ = shell.kill().await;
1402        }
1403        let _ = super::bash_runtime::remove_shell(&done.id);
1404    }
1405
1406    // ── Auto-sync promotion tests (issue #84, phase 2d) ──────────────────
1407
1408    // (a) A fast command via the auto (None) path returns a synchronous result
1409    // with stdout + exit_code and no bash_id — identical to the old foreground.
1410    #[cfg(not(target_os = "windows"))]
1411    #[tokio::test]
1412    async fn auto_path_fast_command_returns_synchronous_result() {
1413        let _command_environment = test_command_environment();
1414        let tool = BashTool::new();
1415        let (tx, _rx) = mpsc::channel(32);
1416        let ctx = ToolExecutionContext {
1417            executing_supervisor: None,
1418            session_id: Some("session_auto_fast"),
1419            root_session_id: None,
1420            tool_call_id: "call_auto_fast",
1421            event_tx: Some(&tx),
1422            available_tool_schemas: None,
1423            bypass_permissions: false,
1424            auto_approve_permissions: false,
1425            plan_read_only: false,
1426            can_async_resume: false,
1427            bash_completion_sink: None,
1428            pre_parsed_args: None,
1429        };
1430
1431        let out = tool
1432            .invoke(
1433                json!({ "command": "echo auto-fast-output" }),
1434                ctx.to_tool_ctx(),
1435            )
1436            .await
1437            .expect("auto fast command should succeed");
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            "fast auto command must not return a bash_id"
1446        );
1447        assert_eq!(payload["exit_code"], 0);
1448        assert_eq!(payload["timed_out"], false);
1449        assert!(payload["stdout"]
1450            .as_str()
1451            .unwrap_or_default()
1452            .contains("auto-fast-output"));
1453    }
1454
1455    // (b) Promotion: with a low test threshold, a long command (sleep) on the
1456    // auto path returns a background {bash_id, running} result, the adopted
1457    // shell appears in running_shells_for_session, and later emits
1458    // BashCompleted with the right id.
1459    #[cfg(not(target_os = "windows"))]
1460    #[tokio::test]
1461    async fn auto_path_promotes_long_command_to_background() {
1462        let _command_environment = test_command_environment();
1463        let tool = BashTool::new();
1464        let session_id = "session_auto_promote";
1465        let (tx, mut rx) = mpsc::channel(8);
1466        let ctx = ToolExecutionContext::for_dispatch(
1467            session_id,
1468            session_id,
1469            "call_auto_promote",
1470            &tx,
1471            &[],
1472            ToolExecutionSessionFlags::default(),
1473            // `can_async_resume` is irrelevant here — this test drives
1474            // promotion directly via run_streaming_command(Some(200)).
1475            true,
1476            None,
1477            None,
1478        );
1479        let cwd = super::workspace_state::workspace_or_process_cwd(Some(session_id));
1480
1481        // Call run_streaming_command directly with a 200ms promotion threshold
1482        // so this test exercises promotion deterministically without depending
1483        // on the production 10s default or the None dispatch arm's
1484        // `can_async_resume` gating.
1485        let result = tool
1486            .run_streaming_command("sleep 10", 60000, Some(200), &cwd, ctx.to_tool_ctx())
1487            .await
1488            .expect("auto promote should succeed");
1489
1490        assert!(result.success);
1491        let payload: Value = serde_json::from_str(&result.result).unwrap();
1492        let bash_id = payload["bash_id"]
1493            .as_str()
1494            .expect("promoted result must have bash_id")
1495            .to_string();
1496        assert_eq!(payload["status"], "running");
1497
1498        let running = super::bash_runtime::running_shells_for_session(session_id);
1499        assert!(
1500            running.contains(&bash_id),
1501            "adopted shell {bash_id} must appear in running_shells, got {running:?}"
1502        );
1503
1504        let event = tokio::time::timeout(Duration::from_secs(15), rx.recv())
1505            .await
1506            .expect("timed out waiting for BashCompleted")
1507            .expect("event channel closed before BashCompleted");
1508        match event {
1509            AgentEvent::BashCompleted {
1510                bash_id: id,
1511                exit_code,
1512                status,
1513                ..
1514            } => {
1515                assert_eq!(id, bash_id);
1516                assert_eq!(exit_code, Some(0));
1517                assert_eq!(status, "completed");
1518            }
1519            other => panic!("expected BashCompleted, got {other:?}"),
1520        }
1521
1522        if let Some(shell) = super::bash_runtime::get_shell(&bash_id) {
1523            let _ = shell.kill().await;
1524        }
1525    }
1526
1527    // (c) run_in_background:Some(false) does NOT promote — blocks/times out
1528    // like the pre-2d foreground path.
1529    #[cfg(not(target_os = "windows"))]
1530    #[tokio::test]
1531    async fn force_sync_does_not_promote_and_times_out() {
1532        let _command_environment = test_command_environment();
1533        let tool = BashTool::new();
1534
1535        let out = tool
1536            .invoke(
1537                json!({
1538                    "command": "sleep 10",
1539                    "run_in_background": false,
1540                    "timeout": 50
1541                }),
1542                ToolCtx::none("t"),
1543            )
1544            .await
1545            .expect("force-sync should produce a timed-out result");
1546        let ToolOutcome::Completed(result) = out else {
1547            panic!("expected Completed")
1548        };
1549
1550        let payload: Value = serde_json::from_str(&result.result).unwrap();
1551        assert!(
1552            payload.get("bash_id").is_none(),
1553            "force-sync must never promote"
1554        );
1555        assert_eq!(payload["timed_out"], true);
1556        assert!(!result.success, "timed-out result must not be successful");
1557    }
1558
1559    // (e) Auto path (run_in_background OMITTED) with can_async_resume == false
1560    // also does NOT promote — it runs purely synchronously, exactly like (c),
1561    // even though promotion is now the default. This is the hook-less-path guard
1562    // (issue #84, phase 2d): a loop that can't suspend+resume a background shell
1563    // (schedule path, agent-core loop) must never orphan one via auto-promotion.
1564    // `execute()` builds `ToolExecutionContext::none`, whose can_async_resume is
1565    // false, so the None dispatch arm passes promote_after_ms = None.
1566    #[cfg(not(target_os = "windows"))]
1567    #[tokio::test]
1568    async fn auto_path_does_not_promote_when_not_resume_capable() {
1569        let _command_environment = test_command_environment();
1570        let tool = BashTool::new();
1571
1572        let out = tool
1573            .invoke(
1574                json!({
1575                    "command": "sleep 10",
1576                    "timeout": 50
1577                }),
1578                ToolCtx::none("t"),
1579            )
1580            .await
1581            .expect("non-resume-capable auto path should produce a result");
1582        let ToolOutcome::Completed(result) = out else {
1583            panic!("expected Completed")
1584        };
1585
1586        let payload: Value = serde_json::from_str(&result.result).unwrap();
1587        assert!(
1588            payload.get("bash_id").is_none(),
1589            "auto path must not promote when can_async_resume is false"
1590        );
1591        assert_eq!(payload["timed_out"], true);
1592        assert!(
1593            !result.success,
1594            "a timed-out command must not report success"
1595        );
1596    }
1597
1598    // (d) adopt_running_child preserves already-captured output: seed lines
1599    // appear in subsequent read_output_since calls.
1600    #[cfg(not(target_os = "windows"))]
1601    #[tokio::test]
1602    async fn adopt_running_child_preserves_seeded_output() {
1603        let _command_environment = test_command_environment();
1604        let shell = bamboo_infrastructure::process::preferred_bash_shell();
1605        let mut cmd = tokio::process::Command::new(&shell.program);
1606        bamboo_infrastructure::process::hide_window_for_tokio_command(&mut cmd);
1607        cmd.arg(shell.arg)
1608            .arg("echo seeded-line-1; echo seeded-line-2; sleep 5")
1609            .stdin(std::process::Stdio::null())
1610            .stdout(std::process::Stdio::piped())
1611            .stderr(std::process::Stdio::piped())
1612            .kill_on_drop(true);
1613        let mut child = cmd.spawn().expect("spawn child");
1614        let stdout_reader = tokio::io::BufReader::new(child.stdout.take().unwrap());
1615        let stderr_reader = tokio::io::BufReader::new(child.stderr.take().unwrap());
1616
1617        // Let the echo output land in the pipe buffer before adoption.
1618        sleep(Duration::from_millis(200)).await;
1619
1620        let session = super::bash_runtime::adopt_running_child(
1621            child,
1622            stdout_reader,
1623            stderr_reader,
1624            vec!["seeded-line-1".to_string(), "seeded-line-2".to_string()],
1625            vec![],
1626            "echo seeded-line-1; echo seeded-line-2; sleep 5",
1627            Some("session_seed_test".to_string()),
1628            test_environment_diagnostics(),
1629            None,
1630            None,
1631        )
1632        .await
1633        .expect("adopt should succeed");
1634
1635        let (lines, _cursor, _dropped) = session.read_output_since(0, None).await;
1636        assert!(
1637            lines.iter().any(|l| l.contains("seeded-line-1")),
1638            "seeded line 1 must be present, got {lines:?}"
1639        );
1640        assert!(
1641            lines.iter().any(|l| l.contains("seeded-line-2")),
1642            "seeded line 2 must be present, got {lines:?}"
1643        );
1644
1645        let _ = session.kill().await;
1646        let _ = super::bash_runtime::remove_shell(&session.id);
1647    }
1648
1649    // ── Phase 2b follow-up: loop-facing completion push (BashCompletionSink) ──
1650
1651    /// A `BashCompletionSink` that records every completion it receives, so a
1652    /// test can assert the producer pushed the right info + output tail.
1653    #[derive(Clone, Default)]
1654    struct RecordingSink {
1655        received: std::sync::Arc<std::sync::Mutex<Vec<bamboo_agent_core::BashCompletionInfo>>>,
1656    }
1657
1658    impl bamboo_agent_core::BashCompletionSink for RecordingSink {
1659        fn on_bash_completed(&self, info: bamboo_agent_core::BashCompletionInfo) {
1660            self.received.lock().unwrap().push(info);
1661        }
1662    }
1663
1664    async fn wait_for_sink(
1665        recorder: &RecordingSink,
1666        what: &str,
1667    ) -> bamboo_agent_core::BashCompletionInfo {
1668        let started = Instant::now();
1669        loop {
1670            if let Some(info) = recorder.received.lock().unwrap().first().cloned() {
1671                return info;
1672            }
1673            if started.elapsed() > Duration::from_secs(5) {
1674                panic!("completion sink was not called for {what}");
1675            }
1676            sleep(Duration::from_millis(50)).await;
1677        }
1678    }
1679
1680    #[tokio::test]
1681    async fn background_completion_pushes_to_sink_with_output_tail() {
1682        let _command_environment = test_command_environment();
1683        let recorder = RecordingSink::default();
1684        let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1685            std::sync::Arc::new(recorder.clone());
1686
1687        let shell = super::bash_runtime::spawn_background(
1688            "echo hello-sink",
1689            None,
1690            None,
1691            Some("sess-sink".to_string()),
1692            false,
1693            Some(sink),
1694        )
1695        .await
1696        .expect("spawn");
1697
1698        let info = wait_for_sink(&recorder, "echo").await;
1699        assert_eq!(info.session_id, "sess-sink");
1700        assert_eq!(info.bash_id, shell.id);
1701        assert_eq!(info.status, "completed");
1702        assert_eq!(info.exit_code, Some(0));
1703        assert!(
1704            info.output_tail.contains("hello-sink"),
1705            "output tail should carry the command output, got: {:?}",
1706            info.output_tail
1707        );
1708
1709        let _ = super::bash_runtime::remove_shell(&shell.id);
1710    }
1711
1712    #[tokio::test]
1713    async fn background_completion_carries_nonzero_exit_code() {
1714        let _command_environment = test_command_environment();
1715        let recorder = RecordingSink::default();
1716        let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1717            std::sync::Arc::new(recorder.clone());
1718
1719        let shell = super::bash_runtime::spawn_background(
1720            "exit 3",
1721            None,
1722            None,
1723            Some("sess-exit".to_string()),
1724            false,
1725            Some(sink),
1726        )
1727        .await
1728        .expect("spawn");
1729
1730        let info = wait_for_sink(&recorder, "exit 3").await;
1731        assert_eq!(info.status, "completed");
1732        assert_eq!(info.exit_code, Some(3));
1733
1734        let _ = super::bash_runtime::remove_shell(&shell.id);
1735    }
1736
1737    /// A killed background shell pushes to the sink with `status="killed"` — this
1738    /// drives the event-driven completion task's `select!` kill branch (kill_notify
1739    /// → start_kill → wait) all the way through to the loop-facing push, so the
1740    /// owning loop is notified even when the shell was terminated rather than
1741    /// exiting on its own.
1742    #[cfg(not(target_os = "windows"))]
1743    #[tokio::test]
1744    async fn killed_background_shell_pushes_killed_to_sink() {
1745        let _command_environment = test_command_environment();
1746        let recorder = RecordingSink::default();
1747        let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1748            std::sync::Arc::new(recorder.clone());
1749
1750        let shell = super::bash_runtime::spawn_background(
1751            "sleep 30",
1752            None,
1753            None,
1754            Some("sess-kill".to_string()),
1755            false,
1756            Some(sink),
1757        )
1758        .await
1759        .expect("spawn");
1760
1761        shell.kill().await.expect("shell should be killable");
1762
1763        let info = wait_for_sink(&recorder, "killed sleep").await;
1764        assert_eq!(info.session_id, "sess-kill");
1765        assert_eq!(info.bash_id, shell.id);
1766        assert_eq!(info.status, "killed");
1767        assert_eq!(info.exit_code, None);
1768
1769        let _ = super::bash_runtime::remove_shell(&shell.id);
1770    }
1771
1772    #[tokio::test]
1773    async fn untagged_shell_does_not_invoke_sink() {
1774        let _command_environment = test_command_environment();
1775        let recorder = RecordingSink::default();
1776        let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1777            std::sync::Arc::new(recorder.clone());
1778
1779        // session_id = None → no owning loop to notify → the sink must not fire,
1780        // even though it is wired.
1781        let shell =
1782            super::bash_runtime::spawn_background("true", None, None, None, false, Some(sink))
1783                .await
1784                .expect("spawn");
1785
1786        let started = Instant::now();
1787        while shell.status() == "running" {
1788            if started.elapsed() > Duration::from_secs(5) {
1789                panic!("shell never completed");
1790            }
1791            sleep(Duration::from_millis(50)).await;
1792        }
1793        // Give the poll task's post-exit emit path time to run.
1794        sleep(Duration::from_millis(200)).await;
1795        assert!(
1796            recorder.received.lock().unwrap().is_empty(),
1797            "an untagged (session-less) shell must not push a completion"
1798        );
1799
1800        let _ = super::bash_runtime::remove_shell(&shell.id);
1801    }
1802    #[cfg(unix)]
1803    #[tokio::test]
1804    async fn local_task_resources_promoted_bash_is_drained_with_its_pumps() {
1805        let _command_environment = test_command_environment();
1806        let resources = crate::LocalTaskResources::new(
1807            "promotion-session",
1808            "promotion-run",
1809            chrono::Utc::now(),
1810        );
1811        let tool = BashTool::new();
1812        let mut ctx = ToolCtx::none("promotion-call");
1813        ctx.session_id = Some(std::sync::Arc::from("promotion-session"));
1814        let cwd = std::env::current_dir().unwrap();
1815        let result = resources
1816            .scope(tool.run_streaming_command(
1817                "printf before; sleep 30",
1818                60_000,
1819                Some(20),
1820                &cwd,
1821                ctx,
1822            ))
1823            .await
1824            .unwrap();
1825        let payload: Value = serde_json::from_str(&result.result).unwrap();
1826        let id = payload["bash_id"].as_str().unwrap();
1827        let shell = super::bash_runtime::get_shell(id).unwrap();
1828        assert_eq!(shell.status(), "running");
1829        tokio::time::timeout(Duration::from_secs(6), resources.close_and_drain())
1830            .await
1831            .unwrap()
1832            .unwrap();
1833        assert_eq!(shell.status(), "completed");
1834        assert!(super::bash_runtime::get_shell(id).is_none());
1835        let (lines, _, _) = shell.read_output_since(0, None).await;
1836        assert!(lines.iter().any(|line| line.contains("before")));
1837    }
1838    mod suspension_contract_tests {
1839        use super::*;
1840        use bamboo_agent_core::tools::{suspension_allowed, with_suspension_disabled};
1841
1842        #[tokio::test]
1843        async fn disabled_suspension_rejects_explicit_background_and_interactive_before_spawn() {
1844            let _environment = test_command_environment();
1845            for args in [
1846                json!({"command":mixed_output_command(),"run_in_background":true}),
1847                json!({"command":mixed_output_command(),"interactive":true}),
1848            ] {
1849                let tool = BashTool::new();
1850                let (tx, mut rx) = mpsc::channel(32);
1851                let mut ctx = ToolCtx::none("disabled-background");
1852                ctx.event_tx = Some(tx);
1853                let outcome = with_suspension_disabled(tool.invoke(args, ctx)).await;
1854                // A RED baseline may create this test's own short shell. Clean
1855                // only the exact returned handle, never a shared inventory.
1856                if let Ok(ToolOutcome::Completed(result)) = &outcome {
1857                    if let Some(id) = serde_json::from_str::<Value>(&result.result)
1858                        .ok()
1859                        .and_then(|value| value["bash_id"].as_str().map(str::to_owned))
1860                    {
1861                        if let Some(shell) = super::super::bash_runtime::get_shell(&id) {
1862                            let _ = shell.kill().await;
1863                        }
1864                    }
1865                }
1866                assert!(
1867                    matches!(outcome,Err(ToolError::Execution(ref reason)) if reason.contains("suspension_disabled")),
1868                    "{outcome:?}"
1869                );
1870                assert!(
1871                    rx.try_recv().is_err(),
1872                    "must not emit a background shell token/event"
1873                );
1874                assert!(suspension_allowed());
1875            }
1876        }
1877
1878        #[tokio::test]
1879        async fn disabled_suspension_auto_and_foreground_keep_synchronous_results() {
1880            let _environment = test_command_environment();
1881            for explicit_foreground in [false, true] {
1882                let mut args = json!({"command":mixed_output_command()});
1883                if explicit_foreground {
1884                    args["run_in_background"] = json!(false);
1885                }
1886                let mut ctx = ToolCtx::none("disabled-foreground");
1887                ctx.can_async_resume = true;
1888                let outcome = with_suspension_disabled(BashTool::new().invoke(args, ctx))
1889                    .await
1890                    .unwrap();
1891                let ToolOutcome::Completed(result) = outcome else {
1892                    panic!("foreground result")
1893                };
1894                let body: Value = serde_json::from_str(&result.result).unwrap();
1895                assert!(result.success);
1896                assert!(body.get("bash_id").is_none());
1897                assert_eq!(body["exit_code"], 0);
1898                assert!(body["stdout"].as_str().unwrap().contains("out"));
1899            }
1900        }
1901    }
1902}