Skip to main content

bamboo_tools/tools/
bash.rs

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