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 CommandEnvironmentDiagnostics, CommandEnvironmentSource, PythonDiscoveryDiagnostics,
663 };
664 use bamboo_infrastructure::test_support::{
665 override_command_environment, CommandEnvironmentOverrideGuard,
666 };
667 use serde_json::Value;
668 use std::collections::HashMap;
669 use tokio::sync::mpsc;
670 use tokio::time::{sleep, Duration, Instant};
671
672 #[cfg(target_os = "windows")]
673 fn mixed_output_command() -> &'static str {
674 "echo out && echo err 1>&2"
675 }
676
677 #[cfg(not(target_os = "windows"))]
678 fn mixed_output_command() -> &'static str {
679 "printf 'out\\n'; printf 'err\\n' 1>&2"
680 }
681
682 #[cfg(target_os = "windows")]
683 fn invalid_utf8_stderr_command() -> String {
684 let shell = bamboo_infrastructure::process::preferred_bash_shell();
685 if shell.arg == "-lc" {
686 "printf '\\377\\n' 1>&2".to_string()
687 } else {
688 "powershell -NoProfile -Command \"$bytes = [byte[]](0xFF,0x0A); [Console]::OpenStandardError().Write($bytes,0,$bytes.Length)\"".to_string()
689 }
690 }
691
692 #[cfg(not(target_os = "windows"))]
693 fn invalid_utf8_stderr_command() -> String {
694 "printf '\\377\\n' 1>&2".to_string()
695 }
696
697 fn test_environment_diagnostics() -> CommandEnvironmentDiagnostics {
698 CommandEnvironmentDiagnostics {
699 source: CommandEnvironmentSource::InheritedProcess,
700 import_shell: None,
701 import_error: Some("test-import-disabled".to_string()),
702 path: Some("/usr/bin:/bin".to_string()),
703 path_entries: Some(2),
704 python: PythonDiscoveryDiagnostics {
705 configured: Some("python3".to_string()),
706 resolved: Some("/usr/bin/python3".to_string()),
707 invocation: Some("/usr/bin/python3".to_string()),
708 source: Some("path".to_string()),
709 tried: vec!["python3".to_string(), "python".to_string()],
710 tried_preview: vec!["python3".to_string(), "python".to_string()],
711 tried_total: 2,
712 tried_truncated: false,
713 hint: None,
714 },
715 }
716 }
717
718 fn test_command_environment() -> CommandEnvironmentOverrideGuard {
719 override_command_environment(
720 HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]),
721 test_environment_diagnostics(),
722 )
723 }
724
725 #[tokio::test]
726 async fn bash_foreground_returns_stdout_stderr_and_streams_tokens() {
727 let _command_environment = test_command_environment();
728 let tool = BashTool::new();
729 let (tx, mut rx) = mpsc::channel(32);
730
731 let out = tool
732 .invoke(
733 json!({
734 "command": mixed_output_command()
735 }),
736 ToolExecutionContext {
737 session_id: Some("session_1"),
738 tool_call_id: "call_1",
739 event_tx: Some(&tx),
740 available_tool_schemas: None,
741 bypass_permissions: false,
742 auto_approve_permissions: false,
743 plan_read_only: false,
744 can_async_resume: false,
745 bash_completion_sink: None,
746 pre_parsed_args: None,
747 }
748 .to_tool_ctx(),
749 )
750 .await
751 .unwrap();
752 let ToolOutcome::Completed(result) = out else {
753 panic!("expected Completed")
754 };
755
756 assert!(result.success);
757
758 let payload: Value = serde_json::from_str(&result.result).unwrap();
759 assert_eq!(payload["timed_out"], false);
760 assert_eq!(payload["exit_code"], 0);
761 assert!(payload["stdout"]
762 .as_str()
763 .unwrap_or_default()
764 .contains("out"));
765 assert!(payload["stderr"]
766 .as_str()
767 .unwrap_or_default()
768 .contains("err"));
769 assert_eq!(payload["environment"]["source"], "process_env");
770 assert_eq!(
771 payload["environment"]["import_error"],
772 "test-import-disabled"
773 );
774 assert_eq!(
775 payload["environment"]["python"]["resolved"],
776 "/usr/bin/python3"
777 );
778 assert_eq!(
779 payload["environment"]["python"]["invocation"],
780 "/usr/bin/python3"
781 );
782 assert_eq!(payload["environment"]["python"]["source"], "path");
783 assert_eq!(
784 payload["environment"]["python"]["tried_preview"][0],
785 "python3"
786 );
787 assert_eq!(payload["environment"]["python"]["tried_total"], 1);
788 assert!(payload["environment"]["python"].get("tried").is_none());
789
790 let mut streamed = Vec::new();
791 while let Ok(event) = rx.try_recv() {
792 if let AgentEvent::ToolToken { content, .. } = event {
793 streamed.push(content);
794 }
795 }
796
797 assert!(streamed.iter().any(|line| line.contains("out")));
798 assert!(streamed.iter().any(|line| line.contains("err")));
799 }
800
801 #[tokio::test]
802 async fn bash_foreground_tolerates_invalid_utf8_stderr() {
803 let _command_environment = test_command_environment();
804 let tool = BashTool::new();
805 let result = tool
806 .invoke(
807 json!({
808 "command": invalid_utf8_stderr_command()
809 }),
810 ToolCtx::none("t"),
811 )
812 .await;
813
814 assert!(result.is_ok(), "invalid UTF-8 stderr should not fail");
815 let ToolOutcome::Completed(result) = result.unwrap() else {
816 panic!("expected Completed")
817 };
818 let payload: Value = serde_json::from_str(&result.result).unwrap();
819 let stderr = payload["stderr"].as_str().unwrap_or_default();
820 assert!(!stderr.is_empty());
821 }
822
823 #[cfg(not(target_os = "windows"))]
824 #[tokio::test]
825 async fn bash_foreground_failure_includes_full_python_tried_list() {
826 let _command_environment = test_command_environment();
827 let tool = BashTool::new();
828 let out = tool
829 .invoke(
830 json!({
831 "command": "false"
832 }),
833 ToolCtx::none("t"),
834 )
835 .await
836 .unwrap();
837 let ToolOutcome::Completed(result) = out else {
838 panic!("expected Completed")
839 };
840
841 assert!(!result.success);
842 let payload: Value = serde_json::from_str(&result.result).unwrap();
843 assert_eq!(payload["exit_code"], 1);
844 assert_eq!(payload["environment"]["python"]["tried_total"], 1);
845 assert_eq!(payload["environment"]["python"]["tried"][0], "python3");
846 }
847
848 #[cfg(not(target_os = "windows"))]
849 #[tokio::test]
850 async fn bash_foreground_sets_stdout_truncated_when_output_exceeds_cap() {
851 let _command_environment = test_command_environment();
852 let tool = BashTool::new();
853 let out = tool
854 .invoke(
855 json!({
856 "command": "i=0; while [ $i -lt 70000 ]; do printf 'aaaaaaaaaa'; i=$((i+1)); done; printf '\\n'"
857 }),
858 ToolCtx::none("t"),
859 )
860 .await
861 .unwrap();
862 let ToolOutcome::Completed(result) = out else {
863 panic!("expected Completed")
864 };
865
866 let payload: Value = serde_json::from_str(&result.result).unwrap();
867 assert_eq!(payload["timed_out"], false);
868 assert_eq!(payload["stdout_truncated"], true);
869 }
870
871 #[cfg(not(target_os = "windows"))]
872 #[tokio::test]
873 async fn bash_background_honors_explicit_timeout() {
874 let _command_environment = test_command_environment();
875 let tool = BashTool::new();
876 let out = tool
877 .invoke(
878 json!({
879 "command": "sleep 2",
880 "run_in_background": true,
881 "timeout": 50
882 }),
883 ToolCtx::none("t"),
884 )
885 .await
886 .unwrap();
887 let ToolOutcome::Completed(result) = out else {
888 panic!("expected Completed")
889 };
890 let payload: Value = serde_json::from_str(&result.result).unwrap();
891 assert_eq!(payload["environment"]["source"], "process_env");
892 assert_eq!(
893 payload["environment"]["python"]["resolved"],
894 "/usr/bin/python3"
895 );
896 assert_eq!(
897 payload["environment"]["python"]["invocation"],
898 "/usr/bin/python3"
899 );
900 assert_eq!(payload["environment"]["python"]["tried_total"], 1);
901 assert!(payload["environment"]["python"].get("tried").is_none());
902 let shell_id = payload["bash_id"].as_str().unwrap().to_string();
903
904 let started = Instant::now();
905 loop {
906 let shell = super::bash_runtime::get_shell(&shell_id).unwrap();
907 if shell.status() == "completed" {
908 break;
909 }
910 if started.elapsed() > Duration::from_secs(2) {
911 panic!("background shell did not stop after timeout");
912 }
913 sleep(Duration::from_millis(25)).await;
914 }
915 }
916
917 #[cfg(not(target_os = "windows"))]
920 #[tokio::test]
921 async fn bash_background_emits_completion_event_with_exit_code() {
922 let _command_environment = test_command_environment();
923 let (tx, mut rx) = mpsc::channel(8);
924 let shell =
925 super::bash_runtime::spawn_background("true", None, Some(tx), None, false, None)
926 .await
927 .expect("background shell should spawn");
928 let expected_id = shell.id.clone();
929
930 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
931 .await
932 .expect("timed out waiting for BashCompleted event")
933 .expect("event channel closed before BashCompleted");
934
935 match event {
936 AgentEvent::BashCompleted {
937 bash_id,
938 command,
939 exit_code,
940 status,
941 } => {
942 assert_eq!(bash_id, expected_id);
943 assert_eq!(command, "true");
944 assert_eq!(exit_code, Some(0));
945 assert_eq!(status, "completed");
946 }
947 other => panic!("expected BashCompleted, got {other:?}"),
948 }
949 }
950
951 #[cfg(not(target_os = "windows"))]
954 #[tokio::test]
955 async fn bash_background_emits_completion_event_for_failing_command() {
956 let _command_environment = test_command_environment();
957 let (tx, mut rx) = mpsc::channel(8);
958 let shell =
959 super::bash_runtime::spawn_background("false", None, Some(tx), None, false, None)
960 .await
961 .expect("background shell should spawn");
962 let expected_id = shell.id.clone();
963
964 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
965 .await
966 .expect("timed out waiting for BashCompleted event")
967 .expect("event channel closed before BashCompleted");
968
969 match event {
970 AgentEvent::BashCompleted {
971 bash_id,
972 exit_code,
973 status,
974 ..
975 } => {
976 assert_eq!(bash_id, expected_id);
977 assert_eq!(exit_code, Some(1));
978 assert_eq!(status, "completed");
979 }
980 other => panic!("expected BashCompleted, got {other:?}"),
981 }
982 }
983
984 #[cfg(not(target_os = "windows"))]
987 #[tokio::test]
988 async fn bash_background_emits_killed_when_shell_is_killed() {
989 let _command_environment = test_command_environment();
990 let (tx, mut rx) = mpsc::channel(8);
991 let shell =
992 super::bash_runtime::spawn_background("sleep 30", None, Some(tx), None, false, None)
993 .await
994 .expect("background shell should spawn");
995 let expected_id = shell.id.clone();
996
997 shell.kill().await.expect("shell should be killable");
998
999 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1000 .await
1001 .expect("timed out waiting for BashCompleted event")
1002 .expect("event channel closed before BashCompleted");
1003
1004 match event {
1005 AgentEvent::BashCompleted {
1006 bash_id,
1007 exit_code,
1008 status,
1009 ..
1010 } => {
1011 assert_eq!(bash_id, expected_id);
1012 assert_eq!(exit_code, None);
1013 assert_eq!(status, "killed");
1014 }
1015 other => panic!("expected BashCompleted, got {other:?}"),
1016 }
1017 }
1018
1019 #[cfg(not(target_os = "windows"))]
1022 #[tokio::test]
1023 async fn bash_background_without_sender_still_completes() {
1024 let _command_environment = test_command_environment();
1025 let shell = super::bash_runtime::spawn_background("true", None, None, None, false, None)
1026 .await
1027 .expect("background shell should spawn");
1028
1029 let started = Instant::now();
1030 loop {
1031 if shell.status() == "completed" {
1032 break;
1033 }
1034 if started.elapsed() > Duration::from_secs(3) {
1035 panic!("shell never reached completed without a sender");
1036 }
1037 sleep(Duration::from_millis(25)).await;
1038 }
1039 }
1040
1041 #[cfg(not(target_os = "windows"))]
1047 #[tokio::test]
1048 async fn bash_background_drops_completion_when_channel_saturated() {
1049 let _command_environment = test_command_environment();
1050 let (tx, mut rx) = mpsc::channel::<AgentEvent>(1);
1052 tx.try_send(AgentEvent::Token {
1053 content: "occupy".into(),
1054 })
1055 .expect("prefill channel slot");
1056
1057 let shell =
1058 super::bash_runtime::spawn_background("true", None, Some(tx), None, false, None)
1059 .await
1060 .expect("background shell should spawn");
1061
1062 sleep(Duration::from_millis(650)).await;
1065
1066 let only = rx
1069 .recv()
1070 .await
1071 .expect("prefilled token should still be present");
1072 assert!(
1073 matches!(only, AgentEvent::Token { .. }),
1074 "expected only the pre-filled token, got {only:?}"
1075 );
1076 assert!(
1077 tokio::time::timeout(Duration::from_millis(50), rx.recv())
1078 .await
1079 .is_err(),
1080 "no BashCompleted should be delivered after a saturation drop"
1081 );
1082 assert_eq!(
1083 shell.status(),
1084 "completed",
1085 "shell must still reach completed after a dropped signal"
1086 );
1087 }
1088
1089 #[cfg(not(target_os = "windows"))]
1094 #[tokio::test]
1095 async fn bash_tool_background_dispatch_emits_completion_event() {
1096 let _command_environment = test_command_environment();
1097 let tool = BashTool::new();
1098 let (tx, mut rx) = mpsc::channel(8);
1099 let ctx = ToolExecutionContext::for_dispatch(
1100 "session_84",
1101 "call_84",
1102 &tx,
1103 &[],
1104 ToolExecutionSessionFlags::default(),
1105 true,
1106 None,
1107 None,
1108 );
1109
1110 let out = tool
1111 .invoke(
1112 json!({ "command": "true", "run_in_background": true }),
1113 ctx.to_tool_ctx(),
1114 )
1115 .await
1116 .expect("background dispatch should succeed");
1117 let ToolOutcome::Completed(result) = out else {
1118 panic!("expected Completed")
1119 };
1120 assert!(result.success);
1121
1122 let payload: Value = serde_json::from_str(&result.result).unwrap();
1123 let bash_id = payload["bash_id"].as_str().unwrap().to_string();
1124 assert_eq!(payload["status"], "running");
1125
1126 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1127 .await
1128 .expect("timed out waiting for BashCompleted event")
1129 .expect("event channel closed before BashCompleted");
1130
1131 match event {
1132 AgentEvent::BashCompleted {
1133 bash_id: id,
1134 exit_code,
1135 status,
1136 ..
1137 } => {
1138 assert_eq!(id, bash_id);
1139 assert_eq!(exit_code, Some(0));
1140 assert_eq!(status, "completed");
1141 }
1142 other => panic!("expected BashCompleted, got {other:?}"),
1143 }
1144 }
1145
1146 #[tokio::test]
1147 async fn bash_resolves_relative_workdir_from_session_workspace() {
1148 let _command_environment = test_command_environment();
1149 let tool = BashTool::new();
1150 let dir = tempfile::tempdir().unwrap();
1151 let base = dir.path().join("base");
1152 let nested = base.join("nested");
1153 tokio::fs::create_dir_all(&nested).await.unwrap();
1154
1155 let session_id = format!("session_{}", uuid::Uuid::new_v4());
1156 super::workspace_state::set_workspace(&session_id, base.canonicalize().unwrap());
1157
1158 let out = tool
1159 .invoke(
1160 json!({
1161 "command": "pwd",
1162 "workdir": "nested"
1163 }),
1164 ToolExecutionContext {
1165 session_id: Some(&session_id),
1166 tool_call_id: "call_1",
1167 event_tx: None,
1168 available_tool_schemas: None,
1169 bypass_permissions: false,
1170 auto_approve_permissions: false,
1171 plan_read_only: false,
1172 can_async_resume: false,
1173 bash_completion_sink: None,
1174 pre_parsed_args: None,
1175 }
1176 .to_tool_ctx(),
1177 )
1178 .await
1179 .unwrap();
1180 let ToolOutcome::Completed(result) = out else {
1181 panic!("expected Completed")
1182 };
1183
1184 let payload: Value = serde_json::from_str(&result.result).unwrap();
1185 let expected =
1186 bamboo_config::paths::path_to_display_string(&nested.canonicalize().unwrap());
1187 assert_eq!(payload["cwd"].as_str().unwrap_or_default(), expected);
1188 }
1189
1190 #[tokio::test]
1191 async fn bash_rejects_workdir_that_is_not_directory() {
1192 let _command_environment = test_command_environment();
1193 let tool = BashTool::new();
1194 let file = tempfile::NamedTempFile::new().unwrap();
1195
1196 let result = tool
1197 .invoke(
1198 json!({
1199 "command": "echo hello",
1200 "workdir": file.path()
1201 }),
1202 ToolCtx::none("t"),
1203 )
1204 .await;
1205
1206 assert!(
1207 matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("directory"))
1208 );
1209 }
1210
1211 #[cfg(not(target_os = "windows"))]
1215 #[tokio::test]
1216 async fn running_shells_for_session_filters_by_session_and_status() {
1217 let _command_environment = test_command_environment();
1218
1219 let a1 = super::bash_runtime::spawn_background(
1221 "sleep 30",
1222 None,
1223 None,
1224 Some("sess-A".to_string()),
1225 false,
1226 None,
1227 )
1228 .await
1229 .expect("spawn a1");
1230 let a2 = super::bash_runtime::spawn_background(
1231 "sleep 30",
1232 None,
1233 None,
1234 Some("sess-A".to_string()),
1235 false,
1236 None,
1237 )
1238 .await
1239 .expect("spawn a2");
1240 let b = super::bash_runtime::spawn_background(
1242 "sleep 30",
1243 None,
1244 None,
1245 Some("sess-B".to_string()),
1246 false,
1247 None,
1248 )
1249 .await
1250 .expect("spawn b");
1251 let untagged =
1253 super::bash_runtime::spawn_background("sleep 30", None, None, None, false, None)
1254 .await
1255 .expect("spawn untagged");
1256 let done = super::bash_runtime::spawn_background(
1258 "true",
1259 None,
1260 None,
1261 Some("sess-A".to_string()),
1262 false,
1263 None,
1264 )
1265 .await
1266 .expect("spawn done");
1267
1268 let started = Instant::now();
1270 loop {
1271 if done.status() == "completed" {
1272 break;
1273 }
1274 if started.elapsed() > Duration::from_secs(3) {
1275 panic!("sess-A fast shell never completed");
1276 }
1277 sleep(Duration::from_millis(25)).await;
1278 }
1279
1280 let mut running = super::bash_runtime::running_shells_for_session("sess-A");
1282 running.sort();
1283 let mut expected = vec![a1.id.clone(), a2.id.clone()];
1284 expected.sort();
1285 assert_eq!(running, expected);
1286
1287 assert_eq!(
1289 super::bash_runtime::running_shells_for_session("sess-B"),
1290 vec![b.id.clone()]
1291 );
1292
1293 for shell in [a1, a2, b, untagged] {
1296 let _ = shell.kill().await;
1297 }
1298 let _ = super::bash_runtime::remove_shell(&done.id);
1299 }
1300
1301 #[cfg(not(target_os = "windows"))]
1306 #[tokio::test]
1307 async fn auto_path_fast_command_returns_synchronous_result() {
1308 let _command_environment = test_command_environment();
1309 let tool = BashTool::new();
1310 let (tx, _rx) = mpsc::channel(32);
1311 let ctx = ToolExecutionContext {
1312 session_id: Some("session_auto_fast"),
1313 tool_call_id: "call_auto_fast",
1314 event_tx: Some(&tx),
1315 available_tool_schemas: None,
1316 bypass_permissions: false,
1317 auto_approve_permissions: false,
1318 plan_read_only: false,
1319 can_async_resume: false,
1320 bash_completion_sink: None,
1321 pre_parsed_args: None,
1322 };
1323
1324 let out = tool
1325 .invoke(
1326 json!({ "command": "echo auto-fast-output" }),
1327 ctx.to_tool_ctx(),
1328 )
1329 .await
1330 .expect("auto fast command should succeed");
1331 let ToolOutcome::Completed(result) = out else {
1332 panic!("expected Completed")
1333 };
1334
1335 let payload: Value = serde_json::from_str(&result.result).unwrap();
1336 assert!(
1337 payload.get("bash_id").is_none(),
1338 "fast auto command must not return a bash_id"
1339 );
1340 assert_eq!(payload["exit_code"], 0);
1341 assert_eq!(payload["timed_out"], false);
1342 assert!(payload["stdout"]
1343 .as_str()
1344 .unwrap_or_default()
1345 .contains("auto-fast-output"));
1346 }
1347
1348 #[cfg(not(target_os = "windows"))]
1353 #[tokio::test]
1354 async fn auto_path_promotes_long_command_to_background() {
1355 let _command_environment = test_command_environment();
1356 let tool = BashTool::new();
1357 let session_id = "session_auto_promote";
1358 let (tx, mut rx) = mpsc::channel(8);
1359 let ctx = ToolExecutionContext::for_dispatch(
1360 session_id,
1361 "call_auto_promote",
1362 &tx,
1363 &[],
1364 ToolExecutionSessionFlags::default(),
1365 true,
1368 None,
1369 None,
1370 );
1371 let cwd = super::workspace_state::workspace_or_process_cwd(Some(session_id));
1372
1373 let result = tool
1378 .run_streaming_command("sleep 10", 60000, Some(200), &cwd, ctx.to_tool_ctx())
1379 .await
1380 .expect("auto promote should succeed");
1381
1382 assert!(result.success);
1383 let payload: Value = serde_json::from_str(&result.result).unwrap();
1384 let bash_id = payload["bash_id"]
1385 .as_str()
1386 .expect("promoted result must have bash_id")
1387 .to_string();
1388 assert_eq!(payload["status"], "running");
1389
1390 let running = super::bash_runtime::running_shells_for_session(session_id);
1391 assert!(
1392 running.contains(&bash_id),
1393 "adopted shell {bash_id} must appear in running_shells, got {running:?}"
1394 );
1395
1396 let event = tokio::time::timeout(Duration::from_secs(15), rx.recv())
1397 .await
1398 .expect("timed out waiting for BashCompleted")
1399 .expect("event channel closed before BashCompleted");
1400 match event {
1401 AgentEvent::BashCompleted {
1402 bash_id: id,
1403 exit_code,
1404 status,
1405 ..
1406 } => {
1407 assert_eq!(id, bash_id);
1408 assert_eq!(exit_code, Some(0));
1409 assert_eq!(status, "completed");
1410 }
1411 other => panic!("expected BashCompleted, got {other:?}"),
1412 }
1413
1414 if let Some(shell) = super::bash_runtime::get_shell(&bash_id) {
1415 let _ = shell.kill().await;
1416 }
1417 }
1418
1419 #[cfg(not(target_os = "windows"))]
1422 #[tokio::test]
1423 async fn force_sync_does_not_promote_and_times_out() {
1424 let _command_environment = test_command_environment();
1425 let tool = BashTool::new();
1426
1427 let out = tool
1428 .invoke(
1429 json!({
1430 "command": "sleep 10",
1431 "run_in_background": false,
1432 "timeout": 50
1433 }),
1434 ToolCtx::none("t"),
1435 )
1436 .await
1437 .expect("force-sync should produce a timed-out result");
1438 let ToolOutcome::Completed(result) = out else {
1439 panic!("expected Completed")
1440 };
1441
1442 let payload: Value = serde_json::from_str(&result.result).unwrap();
1443 assert!(
1444 payload.get("bash_id").is_none(),
1445 "force-sync must never promote"
1446 );
1447 assert_eq!(payload["timed_out"], true);
1448 assert!(!result.success, "timed-out result must not be successful");
1449 }
1450
1451 #[cfg(not(target_os = "windows"))]
1459 #[tokio::test]
1460 async fn auto_path_does_not_promote_when_not_resume_capable() {
1461 let _command_environment = test_command_environment();
1462 let tool = BashTool::new();
1463
1464 let out = tool
1465 .invoke(
1466 json!({
1467 "command": "sleep 10",
1468 "timeout": 50
1469 }),
1470 ToolCtx::none("t"),
1471 )
1472 .await
1473 .expect("non-resume-capable auto path should produce a result");
1474 let ToolOutcome::Completed(result) = out else {
1475 panic!("expected Completed")
1476 };
1477
1478 let payload: Value = serde_json::from_str(&result.result).unwrap();
1479 assert!(
1480 payload.get("bash_id").is_none(),
1481 "auto path must not promote when can_async_resume is false"
1482 );
1483 assert_eq!(payload["timed_out"], true);
1484 assert!(
1485 !result.success,
1486 "a timed-out command must not report success"
1487 );
1488 }
1489
1490 #[cfg(not(target_os = "windows"))]
1493 #[tokio::test]
1494 async fn adopt_running_child_preserves_seeded_output() {
1495 let _command_environment = test_command_environment();
1496 let shell = bamboo_infrastructure::process::preferred_bash_shell();
1497 let mut cmd = tokio::process::Command::new(&shell.program);
1498 bamboo_infrastructure::process::hide_window_for_tokio_command(&mut cmd);
1499 cmd.arg(shell.arg)
1500 .arg("echo seeded-line-1; echo seeded-line-2; sleep 5")
1501 .stdin(std::process::Stdio::null())
1502 .stdout(std::process::Stdio::piped())
1503 .stderr(std::process::Stdio::piped())
1504 .kill_on_drop(true);
1505 let mut child = cmd.spawn().expect("spawn child");
1506 let stdout_reader = tokio::io::BufReader::new(child.stdout.take().unwrap());
1507 let stderr_reader = tokio::io::BufReader::new(child.stderr.take().unwrap());
1508
1509 sleep(Duration::from_millis(200)).await;
1511
1512 let session = super::bash_runtime::adopt_running_child(
1513 child,
1514 stdout_reader,
1515 stderr_reader,
1516 vec!["seeded-line-1".to_string(), "seeded-line-2".to_string()],
1517 vec![],
1518 "echo seeded-line-1; echo seeded-line-2; sleep 5",
1519 Some("session_seed_test".to_string()),
1520 test_environment_diagnostics(),
1521 None,
1522 None,
1523 )
1524 .await
1525 .expect("adopt should succeed");
1526
1527 let (lines, _cursor, _dropped) = session.read_output_since(0, None).await;
1528 assert!(
1529 lines.iter().any(|l| l.contains("seeded-line-1")),
1530 "seeded line 1 must be present, got {lines:?}"
1531 );
1532 assert!(
1533 lines.iter().any(|l| l.contains("seeded-line-2")),
1534 "seeded line 2 must be present, got {lines:?}"
1535 );
1536
1537 let _ = session.kill().await;
1538 let _ = super::bash_runtime::remove_shell(&session.id);
1539 }
1540
1541 #[derive(Clone, Default)]
1546 struct RecordingSink {
1547 received: std::sync::Arc<std::sync::Mutex<Vec<bamboo_agent_core::BashCompletionInfo>>>,
1548 }
1549
1550 impl bamboo_agent_core::BashCompletionSink for RecordingSink {
1551 fn on_bash_completed(&self, info: bamboo_agent_core::BashCompletionInfo) {
1552 self.received.lock().unwrap().push(info);
1553 }
1554 }
1555
1556 async fn wait_for_sink(
1557 recorder: &RecordingSink,
1558 what: &str,
1559 ) -> bamboo_agent_core::BashCompletionInfo {
1560 let started = Instant::now();
1561 loop {
1562 if let Some(info) = recorder.received.lock().unwrap().first().cloned() {
1563 return info;
1564 }
1565 if started.elapsed() > Duration::from_secs(5) {
1566 panic!("completion sink was not called for {what}");
1567 }
1568 sleep(Duration::from_millis(50)).await;
1569 }
1570 }
1571
1572 #[tokio::test]
1573 async fn background_completion_pushes_to_sink_with_output_tail() {
1574 let _command_environment = test_command_environment();
1575 let recorder = RecordingSink::default();
1576 let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1577 std::sync::Arc::new(recorder.clone());
1578
1579 let shell = super::bash_runtime::spawn_background(
1580 "echo hello-sink",
1581 None,
1582 None,
1583 Some("sess-sink".to_string()),
1584 false,
1585 Some(sink),
1586 )
1587 .await
1588 .expect("spawn");
1589
1590 let info = wait_for_sink(&recorder, "echo").await;
1591 assert_eq!(info.session_id, "sess-sink");
1592 assert_eq!(info.bash_id, shell.id);
1593 assert_eq!(info.status, "completed");
1594 assert_eq!(info.exit_code, Some(0));
1595 assert!(
1596 info.output_tail.contains("hello-sink"),
1597 "output tail should carry the command output, got: {:?}",
1598 info.output_tail
1599 );
1600
1601 let _ = super::bash_runtime::remove_shell(&shell.id);
1602 }
1603
1604 #[tokio::test]
1605 async fn background_completion_carries_nonzero_exit_code() {
1606 let _command_environment = test_command_environment();
1607 let recorder = RecordingSink::default();
1608 let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1609 std::sync::Arc::new(recorder.clone());
1610
1611 let shell = super::bash_runtime::spawn_background(
1612 "exit 3",
1613 None,
1614 None,
1615 Some("sess-exit".to_string()),
1616 false,
1617 Some(sink),
1618 )
1619 .await
1620 .expect("spawn");
1621
1622 let info = wait_for_sink(&recorder, "exit 3").await;
1623 assert_eq!(info.status, "completed");
1624 assert_eq!(info.exit_code, Some(3));
1625
1626 let _ = super::bash_runtime::remove_shell(&shell.id);
1627 }
1628
1629 #[cfg(not(target_os = "windows"))]
1635 #[tokio::test]
1636 async fn killed_background_shell_pushes_killed_to_sink() {
1637 let _command_environment = test_command_environment();
1638 let recorder = RecordingSink::default();
1639 let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1640 std::sync::Arc::new(recorder.clone());
1641
1642 let shell = super::bash_runtime::spawn_background(
1643 "sleep 30",
1644 None,
1645 None,
1646 Some("sess-kill".to_string()),
1647 false,
1648 Some(sink),
1649 )
1650 .await
1651 .expect("spawn");
1652
1653 shell.kill().await.expect("shell should be killable");
1654
1655 let info = wait_for_sink(&recorder, "killed sleep").await;
1656 assert_eq!(info.session_id, "sess-kill");
1657 assert_eq!(info.bash_id, shell.id);
1658 assert_eq!(info.status, "killed");
1659 assert_eq!(info.exit_code, None);
1660
1661 let _ = super::bash_runtime::remove_shell(&shell.id);
1662 }
1663
1664 #[tokio::test]
1665 async fn untagged_shell_does_not_invoke_sink() {
1666 let _command_environment = test_command_environment();
1667 let recorder = RecordingSink::default();
1668 let sink: std::sync::Arc<dyn bamboo_agent_core::BashCompletionSink> =
1669 std::sync::Arc::new(recorder.clone());
1670
1671 let shell =
1674 super::bash_runtime::spawn_background("true", None, None, None, false, Some(sink))
1675 .await
1676 .expect("spawn");
1677
1678 let started = Instant::now();
1679 while shell.status() == "running" {
1680 if started.elapsed() > Duration::from_secs(5) {
1681 panic!("shell never completed");
1682 }
1683 sleep(Duration::from_millis(50)).await;
1684 }
1685 sleep(Duration::from_millis(200)).await;
1687 assert!(
1688 recorder.received.lock().unwrap().is_empty(),
1689 "an untagged (session-less) shell must not push a completion"
1690 );
1691
1692 let _ = super::bash_runtime::remove_shell(&shell.id);
1693 }
1694}