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
23async 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
44const 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 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 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 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 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 let promotion_fired =
369 !streams_closed && promote_after_ms.is_some() && promote_after_ms.unwrap() < timeout_ms;
370
371 if streams_closed {
372 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 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 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 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 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 self.run_streaming_command(command, timeout_ms, None, &cwd, ctx)
656 .await
657 .map(ToolOutcome::Completed)
658 }
659 None => {
660 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 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 #[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 #[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 #[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 #[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 #[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 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 sleep(Duration::from_millis(650)).await;
1167
1168 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 #[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 #[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 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 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 let untagged =
1358 super::bash_runtime::spawn_background("sleep 30", None, None, None, false, None)
1359 .await
1360 .expect("spawn untagged");
1361 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 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 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 assert_eq!(
1394 super::bash_runtime::running_shells_for_session("sess-B"),
1395 vec![b.id.clone()]
1396 );
1397
1398 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 #[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 #[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 true,
1476 None,
1477 None,
1478 );
1479 let cwd = super::workspace_state::workspace_or_process_cwd(Some(session_id));
1480
1481 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 #[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 #[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 #[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 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 #[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 #[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 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 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 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}