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