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
22const 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 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 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 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 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 let promotion_fired =
352 !streams_closed && promote_after_ms.is_some() && promote_after_ms.unwrap() < timeout_ms;
353
354 if streams_closed {
355 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 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 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 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 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 self.run_streaming_command(command, timeout_ms, None, &cwd, ctx)
632 .await
633 .map(ToolOutcome::Completed)
634 }
635 None => {
636 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 #[cfg(not(target_os = "windows"))]
917 #[tokio::test]
918 async fn bash_background_emits_completion_event_with_exit_code() {
919 prime_test_command_environment();
920 let (tx, mut rx) = mpsc::channel(8);
921 let shell = super::bash_runtime::spawn_background("true", None, Some(tx), None, false, None)
922 .await
923 .expect("background shell should spawn");
924 let expected_id = shell.id.clone();
925
926 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
927 .await
928 .expect("timed out waiting for BashCompleted event")
929 .expect("event channel closed before BashCompleted");
930
931 match event {
932 AgentEvent::BashCompleted {
933 bash_id,
934 command,
935 exit_code,
936 status,
937 } => {
938 assert_eq!(bash_id, expected_id);
939 assert_eq!(command, "true");
940 assert_eq!(exit_code, Some(0));
941 assert_eq!(status, "completed");
942 }
943 other => panic!("expected BashCompleted, got {other:?}"),
944 }
945 }
946
947 #[cfg(not(target_os = "windows"))]
950 #[tokio::test]
951 async fn bash_background_emits_completion_event_for_failing_command() {
952 prime_test_command_environment();
953 let (tx, mut rx) = mpsc::channel(8);
954 let shell = super::bash_runtime::spawn_background("false", None, Some(tx), None, false, None)
955 .await
956 .expect("background shell should spawn");
957 let expected_id = shell.id.clone();
958
959 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
960 .await
961 .expect("timed out waiting for BashCompleted event")
962 .expect("event channel closed before BashCompleted");
963
964 match event {
965 AgentEvent::BashCompleted {
966 bash_id,
967 exit_code,
968 status,
969 ..
970 } => {
971 assert_eq!(bash_id, expected_id);
972 assert_eq!(exit_code, Some(1));
973 assert_eq!(status, "completed");
974 }
975 other => panic!("expected BashCompleted, got {other:?}"),
976 }
977 }
978
979 #[cfg(not(target_os = "windows"))]
982 #[tokio::test]
983 async fn bash_background_emits_killed_when_shell_is_killed() {
984 prime_test_command_environment();
985 let (tx, mut rx) = mpsc::channel(8);
986 let shell = super::bash_runtime::spawn_background("sleep 30", None, Some(tx), None, false, None)
987 .await
988 .expect("background shell should spawn");
989 let expected_id = shell.id.clone();
990
991 shell.kill().await.expect("shell should be killable");
992
993 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
994 .await
995 .expect("timed out waiting for BashCompleted event")
996 .expect("event channel closed before BashCompleted");
997
998 match event {
999 AgentEvent::BashCompleted {
1000 bash_id,
1001 exit_code,
1002 status,
1003 ..
1004 } => {
1005 assert_eq!(bash_id, expected_id);
1006 assert_eq!(exit_code, None);
1007 assert_eq!(status, "killed");
1008 }
1009 other => panic!("expected BashCompleted, got {other:?}"),
1010 }
1011 }
1012
1013 #[cfg(not(target_os = "windows"))]
1016 #[tokio::test]
1017 async fn bash_background_without_sender_still_completes() {
1018 prime_test_command_environment();
1019 let shell = super::bash_runtime::spawn_background("true", None, None, None, false, None)
1020 .await
1021 .expect("background shell should spawn");
1022
1023 let started = Instant::now();
1024 loop {
1025 if shell.status() == "completed" {
1026 break;
1027 }
1028 if started.elapsed() > Duration::from_secs(3) {
1029 panic!("shell never reached completed without a sender");
1030 }
1031 sleep(Duration::from_millis(25)).await;
1032 }
1033 }
1034
1035 #[cfg(not(target_os = "windows"))]
1041 #[tokio::test]
1042 async fn bash_background_drops_completion_when_channel_saturated() {
1043 prime_test_command_environment();
1044 let (tx, mut rx) = mpsc::channel::<AgentEvent>(1);
1046 tx.try_send(AgentEvent::Token {
1047 content: "occupy".into(),
1048 })
1049 .expect("prefill channel slot");
1050
1051 let shell = super::bash_runtime::spawn_background("true", None, Some(tx), None, false, None)
1052 .await
1053 .expect("background shell should spawn");
1054
1055 sleep(Duration::from_millis(650)).await;
1058
1059 let only = rx
1062 .recv()
1063 .await
1064 .expect("prefilled token should still be present");
1065 assert!(
1066 matches!(only, AgentEvent::Token { .. }),
1067 "expected only the pre-filled token, got {only:?}"
1068 );
1069 assert!(
1070 tokio::time::timeout(Duration::from_millis(50), rx.recv())
1071 .await
1072 .is_err(),
1073 "no BashCompleted should be delivered after a saturation drop"
1074 );
1075 assert_eq!(
1076 shell.status(),
1077 "completed",
1078 "shell must still reach completed after a dropped signal"
1079 );
1080 }
1081
1082 #[cfg(not(target_os = "windows"))]
1087 #[tokio::test]
1088 async fn bash_tool_background_dispatch_emits_completion_event() {
1089 prime_test_command_environment();
1090 let tool = BashTool::new();
1091 let (tx, mut rx) = mpsc::channel(8);
1092 let ctx = ToolExecutionContext::for_dispatch(
1093 "session_84",
1094 "call_84",
1095 &tx,
1096 &[],
1097 ToolExecutionSessionFlags::default(),
1098 true,
1099 None,
1100 None,
1101 );
1102
1103 let out = tool
1104 .invoke(
1105 json!({ "command": "true", "run_in_background": true }),
1106 ctx.to_tool_ctx(),
1107 )
1108 .await
1109 .expect("background dispatch should succeed");
1110 let ToolOutcome::Completed(result) = out else {
1111 panic!("expected Completed")
1112 };
1113 assert!(result.success);
1114
1115 let payload: Value = serde_json::from_str(&result.result).unwrap();
1116 let bash_id = payload["bash_id"].as_str().unwrap().to_string();
1117 assert_eq!(payload["status"], "running");
1118
1119 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1120 .await
1121 .expect("timed out waiting for BashCompleted event")
1122 .expect("event channel closed before BashCompleted");
1123
1124 match event {
1125 AgentEvent::BashCompleted {
1126 bash_id: id,
1127 exit_code,
1128 status,
1129 ..
1130 } => {
1131 assert_eq!(id, bash_id);
1132 assert_eq!(exit_code, Some(0));
1133 assert_eq!(status, "completed");
1134 }
1135 other => panic!("expected BashCompleted, got {other:?}"),
1136 }
1137 }
1138
1139 #[tokio::test]
1140 async fn bash_resolves_relative_workdir_from_session_workspace() {
1141 prime_test_command_environment();
1142 let tool = BashTool::new();
1143 let dir = tempfile::tempdir().unwrap();
1144 let base = dir.path().join("base");
1145 let nested = base.join("nested");
1146 tokio::fs::create_dir_all(&nested).await.unwrap();
1147
1148 let session_id = format!("session_{}", uuid::Uuid::new_v4());
1149 super::workspace_state::set_workspace(&session_id, base.canonicalize().unwrap());
1150
1151 let out = tool
1152 .invoke(
1153 json!({
1154 "command": "pwd",
1155 "workdir": "nested"
1156 }),
1157 ToolExecutionContext {
1158 session_id: Some(&session_id),
1159 tool_call_id: "call_1",
1160 event_tx: None,
1161 available_tool_schemas: None,
1162 bypass_permissions: false,
1163 can_async_resume: false,
1164 bash_completion_sink: None,
1165 pre_parsed_args: None,
1166 }
1167 .to_tool_ctx(),
1168 )
1169 .await
1170 .unwrap();
1171 let ToolOutcome::Completed(result) = out else {
1172 panic!("expected Completed")
1173 };
1174
1175 let payload: Value = serde_json::from_str(&result.result).unwrap();
1176 let expected =
1177 bamboo_config::paths::path_to_display_string(&nested.canonicalize().unwrap());
1178 assert_eq!(payload["cwd"].as_str().unwrap_or_default(), expected);
1179 }
1180
1181 #[tokio::test]
1182 async fn bash_rejects_workdir_that_is_not_directory() {
1183 prime_test_command_environment();
1184 let tool = BashTool::new();
1185 let file = tempfile::NamedTempFile::new().unwrap();
1186
1187 let result = tool
1188 .invoke(
1189 json!({
1190 "command": "echo hello",
1191 "workdir": file.path()
1192 }),
1193 ToolCtx::none("t"),
1194 )
1195 .await;
1196
1197 assert!(
1198 matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("directory"))
1199 );
1200 }
1201
1202 #[cfg(not(target_os = "windows"))]
1206 #[tokio::test]
1207 async fn running_shells_for_session_filters_by_session_and_status() {
1208 prime_test_command_environment();
1209
1210 let a1 = super::bash_runtime::spawn_background(
1212 "sleep 30",
1213 None,
1214 None,
1215 Some("sess-A".to_string()),
1216 false,
1217 None,
1218 )
1219 .await
1220 .expect("spawn a1");
1221 let a2 = super::bash_runtime::spawn_background(
1222 "sleep 30",
1223 None,
1224 None,
1225 Some("sess-A".to_string()),
1226 false,
1227 None,
1228 )
1229 .await
1230 .expect("spawn a2");
1231 let b = super::bash_runtime::spawn_background(
1233 "sleep 30",
1234 None,
1235 None,
1236 Some("sess-B".to_string()),
1237 false,
1238 None,
1239 )
1240 .await
1241 .expect("spawn b");
1242 let untagged = super::bash_runtime::spawn_background("sleep 30", None, None, None, false, None)
1244 .await
1245 .expect("spawn untagged");
1246 let done = super::bash_runtime::spawn_background(
1248 "true",
1249 None,
1250 None,
1251 Some("sess-A".to_string()),
1252 false,
1253 None,
1254 )
1255 .await
1256 .expect("spawn done");
1257
1258 let started = Instant::now();
1260 loop {
1261 if done.status() == "completed" {
1262 break;
1263 }
1264 if started.elapsed() > Duration::from_secs(3) {
1265 panic!("sess-A fast shell never completed");
1266 }
1267 sleep(Duration::from_millis(25)).await;
1268 }
1269
1270 let mut running = super::bash_runtime::running_shells_for_session("sess-A");
1272 running.sort();
1273 let mut expected = vec![a1.id.clone(), a2.id.clone()];
1274 expected.sort();
1275 assert_eq!(running, expected);
1276
1277 assert_eq!(
1279 super::bash_runtime::running_shells_for_session("sess-B"),
1280 vec![b.id.clone()]
1281 );
1282
1283 for shell in [a1, a2, b, untagged] {
1286 let _ = shell.kill().await;
1287 }
1288 let _ = super::bash_runtime::remove_shell(&done.id);
1289 }
1290
1291 #[cfg(not(target_os = "windows"))]
1296 #[tokio::test]
1297 async fn auto_path_fast_command_returns_synchronous_result() {
1298 prime_test_command_environment();
1299 let tool = BashTool::new();
1300 let (tx, _rx) = mpsc::channel(32);
1301 let ctx = ToolExecutionContext {
1302 session_id: Some("session_auto_fast"),
1303 tool_call_id: "call_auto_fast",
1304 event_tx: Some(&tx),
1305 available_tool_schemas: None,
1306 bypass_permissions: false,
1307 can_async_resume: false,
1308 bash_completion_sink: None,
1309 pre_parsed_args: None,
1310 };
1311
1312 let out = tool
1313 .invoke(json!({ "command": "echo auto-fast-output" }), ctx.to_tool_ctx())
1314 .await
1315 .expect("auto fast command should succeed");
1316 let ToolOutcome::Completed(result) = out else {
1317 panic!("expected Completed")
1318 };
1319
1320 let payload: Value = serde_json::from_str(&result.result).unwrap();
1321 assert!(
1322 payload.get("bash_id").is_none(),
1323 "fast auto command must not return a bash_id"
1324 );
1325 assert_eq!(payload["exit_code"], 0);
1326 assert_eq!(payload["timed_out"], false);
1327 assert!(payload["stdout"]
1328 .as_str()
1329 .unwrap_or_default()
1330 .contains("auto-fast-output"));
1331 }
1332
1333 #[cfg(not(target_os = "windows"))]
1338 #[tokio::test]
1339 async fn auto_path_promotes_long_command_to_background() {
1340 prime_test_command_environment();
1341 let tool = BashTool::new();
1342 let session_id = "session_auto_promote";
1343 let (tx, mut rx) = mpsc::channel(8);
1344 let ctx = ToolExecutionContext::for_dispatch(
1345 session_id,
1346 "call_auto_promote",
1347 &tx,
1348 &[],
1349 ToolExecutionSessionFlags::default(),
1350 true,
1353 None,
1354 None,
1355 );
1356 let cwd = super::workspace_state::workspace_or_process_cwd(Some(session_id));
1357
1358 let result = tool
1363 .run_streaming_command("sleep 10", 60000, Some(200), &cwd, ctx.to_tool_ctx())
1364 .await
1365 .expect("auto promote should succeed");
1366
1367 assert!(result.success);
1368 let payload: Value = serde_json::from_str(&result.result).unwrap();
1369 let bash_id = payload["bash_id"]
1370 .as_str()
1371 .expect("promoted result must have bash_id")
1372 .to_string();
1373 assert_eq!(payload["status"], "running");
1374
1375 let running = super::bash_runtime::running_shells_for_session(session_id);
1376 assert!(
1377 running.contains(&bash_id),
1378 "adopted shell {bash_id} must appear in running_shells, got {running:?}"
1379 );
1380
1381 let event = tokio::time::timeout(Duration::from_secs(15), rx.recv())
1382 .await
1383 .expect("timed out waiting for BashCompleted")
1384 .expect("event channel closed before BashCompleted");
1385 match event {
1386 AgentEvent::BashCompleted {
1387 bash_id: id,
1388 exit_code,
1389 status,
1390 ..
1391 } => {
1392 assert_eq!(id, bash_id);
1393 assert_eq!(exit_code, Some(0));
1394 assert_eq!(status, "completed");
1395 }
1396 other => panic!("expected BashCompleted, got {other:?}"),
1397 }
1398
1399 if let Some(shell) = super::bash_runtime::get_shell(&bash_id) {
1400 let _ = shell.kill().await;
1401 }
1402 }
1403
1404 #[cfg(not(target_os = "windows"))]
1407 #[tokio::test]
1408 async fn force_sync_does_not_promote_and_times_out() {
1409 prime_test_command_environment();
1410 let tool = BashTool::new();
1411
1412 let out = tool
1413 .invoke(
1414 json!({
1415 "command": "sleep 10",
1416 "run_in_background": false,
1417 "timeout": 50
1418 }),
1419 ToolCtx::none("t"),
1420 )
1421 .await
1422 .expect("force-sync should produce a timed-out result");
1423 let ToolOutcome::Completed(result) = out else {
1424 panic!("expected Completed")
1425 };
1426
1427 let payload: Value = serde_json::from_str(&result.result).unwrap();
1428 assert!(
1429 payload.get("bash_id").is_none(),
1430 "force-sync must never promote"
1431 );
1432 assert_eq!(payload["timed_out"], true);
1433 assert!(!result.success, "timed-out result must not be successful");
1434 }
1435
1436 #[cfg(not(target_os = "windows"))]
1444 #[tokio::test]
1445 async fn auto_path_does_not_promote_when_not_resume_capable() {
1446 prime_test_command_environment();
1447 let tool = BashTool::new();
1448
1449 let out = tool
1450 .invoke(
1451 json!({
1452 "command": "sleep 10",
1453 "timeout": 50
1454 }),
1455 ToolCtx::none("t"),
1456 )
1457 .await
1458 .expect("non-resume-capable auto path should produce a result");
1459 let ToolOutcome::Completed(result) = out else {
1460 panic!("expected Completed")
1461 };
1462
1463 let payload: Value = serde_json::from_str(&result.result).unwrap();
1464 assert!(
1465 payload.get("bash_id").is_none(),
1466 "auto path must not promote when can_async_resume is false"
1467 );
1468 assert_eq!(payload["timed_out"], true);
1469 assert!(
1470 !result.success,
1471 "a timed-out command must not report success"
1472 );
1473 }
1474
1475 #[cfg(not(target_os = "windows"))]
1478 #[tokio::test]
1479 async fn adopt_running_child_preserves_seeded_output() {
1480 prime_test_command_environment();
1481 let shell = bamboo_infrastructure::process::preferred_bash_shell();
1482 let mut cmd = tokio::process::Command::new(&shell.program);
1483 bamboo_infrastructure::process::hide_window_for_tokio_command(&mut cmd);
1484 cmd.arg(shell.arg)
1485 .arg("echo seeded-line-1; echo seeded-line-2; sleep 5")
1486 .stdin(std::process::Stdio::null())
1487 .stdout(std::process::Stdio::piped())
1488 .stderr(std::process::Stdio::piped())
1489 .kill_on_drop(true);
1490 let mut child = cmd.spawn().expect("spawn child");
1491 let stdout_reader = tokio::io::BufReader::new(child.stdout.take().unwrap());
1492 let stderr_reader = tokio::io::BufReader::new(child.stderr.take().unwrap());
1493
1494 sleep(Duration::from_millis(200)).await;
1496
1497 let session = super::bash_runtime::adopt_running_child(
1498 child,
1499 stdout_reader,
1500 stderr_reader,
1501 vec!["seeded-line-1".to_string(), "seeded-line-2".to_string()],
1502 vec![],
1503 "echo seeded-line-1; echo seeded-line-2; sleep 5",
1504 Some("session_seed_test".to_string()),
1505 test_environment_diagnostics(),
1506 None,
1507 None,
1508 )
1509 .await
1510 .expect("adopt should succeed");
1511
1512 let (lines, _cursor, _dropped) = session.read_output_since(0, None).await;
1513 assert!(
1514 lines.iter().any(|l| l.contains("seeded-line-1")),
1515 "seeded line 1 must be present, got {lines:?}"
1516 );
1517 assert!(
1518 lines.iter().any(|l| l.contains("seeded-line-2")),
1519 "seeded line 2 must be present, got {lines:?}"
1520 );
1521
1522 let _ = session.kill().await;
1523 let _ = super::bash_runtime::remove_shell(&session.id);
1524 }
1525
1526 #[derive(Clone, Default)]
1531 struct RecordingSink {
1532 received: std::sync::Arc<std::sync::Mutex<Vec<bamboo_agent_core::BashCompletionInfo>>>,
1533 }
1534
1535 impl bamboo_agent_core::BashCompletionSink for RecordingSink {
1536 fn on_bash_completed(&self, info: bamboo_agent_core::BashCompletionInfo) {
1537 self.received.lock().unwrap().push(info);
1538 }
1539 }
1540
1541 async fn wait_for_sink(
1542 recorder: &RecordingSink,
1543 what: &str,
1544 ) -> bamboo_agent_core::BashCompletionInfo {
1545 let started = Instant::now();
1546 loop {
1547 if let Some(info) = recorder.received.lock().unwrap().first().cloned() {
1548 return info;
1549 }
1550 if started.elapsed() > Duration::from_secs(5) {
1551 panic!("completion sink was not called for {what}");
1552 }
1553 sleep(Duration::from_millis(50)).await;
1554 }
1555 }
1556
1557 #[tokio::test]
1558 async fn background_completion_pushes_to_sink_with_output_tail() {
1559 prime_test_command_environment();
1560 let recorder = RecordingSink::default();
1561 let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1562 std::sync::Arc::new(recorder.clone());
1563
1564 let shell = super::bash_runtime::spawn_background(
1565 "echo hello-sink",
1566 None,
1567 None,
1568 Some("sess-sink".to_string()),
1569 false,
1570 Some(sink),
1571 )
1572 .await
1573 .expect("spawn");
1574
1575 let info = wait_for_sink(&recorder, "echo").await;
1576 assert_eq!(info.session_id, "sess-sink");
1577 assert_eq!(info.bash_id, shell.id);
1578 assert_eq!(info.status, "completed");
1579 assert_eq!(info.exit_code, Some(0));
1580 assert!(
1581 info.output_tail.contains("hello-sink"),
1582 "output tail should carry the command output, got: {:?}",
1583 info.output_tail
1584 );
1585
1586 let _ = super::bash_runtime::remove_shell(&shell.id);
1587 }
1588
1589 #[tokio::test]
1590 async fn background_completion_carries_nonzero_exit_code() {
1591 prime_test_command_environment();
1592 let recorder = RecordingSink::default();
1593 let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1594 std::sync::Arc::new(recorder.clone());
1595
1596 let shell = super::bash_runtime::spawn_background(
1597 "exit 3",
1598 None,
1599 None,
1600 Some("sess-exit".to_string()),
1601 false,
1602 Some(sink),
1603 )
1604 .await
1605 .expect("spawn");
1606
1607 let info = wait_for_sink(&recorder, "exit 3").await;
1608 assert_eq!(info.status, "completed");
1609 assert_eq!(info.exit_code, Some(3));
1610
1611 let _ = super::bash_runtime::remove_shell(&shell.id);
1612 }
1613
1614 #[cfg(not(target_os = "windows"))]
1620 #[tokio::test]
1621 async fn killed_background_shell_pushes_killed_to_sink() {
1622 prime_test_command_environment();
1623 let recorder = RecordingSink::default();
1624 let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1625 std::sync::Arc::new(recorder.clone());
1626
1627 let shell = super::bash_runtime::spawn_background(
1628 "sleep 30",
1629 None,
1630 None,
1631 Some("sess-kill".to_string()),
1632 false,
1633 Some(sink),
1634 )
1635 .await
1636 .expect("spawn");
1637
1638 shell.kill().await.expect("shell should be killable");
1639
1640 let info = wait_for_sink(&recorder, "killed sleep").await;
1641 assert_eq!(info.session_id, "sess-kill");
1642 assert_eq!(info.bash_id, shell.id);
1643 assert_eq!(info.status, "killed");
1644 assert_eq!(info.exit_code, None);
1645
1646 let _ = super::bash_runtime::remove_shell(&shell.id);
1647 }
1648
1649 #[tokio::test]
1650 async fn untagged_shell_does_not_invoke_sink() {
1651 prime_test_command_environment();
1652 let recorder = RecordingSink::default();
1653 let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1654 std::sync::Arc::new(recorder.clone());
1655
1656 let shell =
1659 super::bash_runtime::spawn_background("true", None, None, None, false, Some(sink))
1660 .await
1661 .expect("spawn");
1662
1663 let started = Instant::now();
1664 while shell.status() == "running" {
1665 if started.elapsed() > Duration::from_secs(5) {
1666 panic!("shell never completed");
1667 }
1668 sleep(Duration::from_millis(50)).await;
1669 }
1670 sleep(Duration::from_millis(200)).await;
1672 assert!(
1673 recorder.received.lock().unwrap().is_empty(),
1674 "an untagged (session-less) shell must not push a completion"
1675 );
1676
1677 let _ = super::bash_runtime::remove_shell(&shell.id);
1678 }
1679}