Skip to main content

agent_code_lib/services/
shell_passthrough.rs

1//! Shell passthrough: capture subprocess output and build context messages.
2//!
3//! The `!` prefix in the REPL runs a shell command directly, streams output
4//! to the terminal, captures it into a buffer, and injects the result into
5//! conversation history as a `UserMessage` with `is_meta: true`.
6//!
7//! This module extracts the capture and message-building logic so it can be
8//! tested independently of the REPL event loop.
9
10use crate::llm::message::{ContentBlock, Message, UserMessage};
11use std::io::{BufRead, BufReader, Read};
12use std::path::Path;
13use std::process::{Command, Stdio};
14use uuid::Uuid;
15
16/// Maximum bytes captured from shell output before truncation.
17pub const MAX_CAPTURE_BYTES: usize = 50 * 1024; // 50KB
18
19/// Result of capturing shell command output.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct CapturedOutput {
22    /// The captured text (may be truncated).
23    pub text: String,
24    /// Whether the output was truncated at `MAX_CAPTURE_BYTES`.
25    pub truncated: bool,
26    /// The exit code of the process, if available.
27    pub exit_code: Option<i32>,
28}
29
30/// Capture text into a buffer with a size limit.
31///
32/// Reads lines from `reader`, calls `on_line` for each (e.g., to print to
33/// the terminal), and appends to the buffer until `MAX_CAPTURE_BYTES` is reached.
34pub fn capture_lines<R: Read>(
35    reader: R,
36    buffer: &mut String,
37    truncated: &mut bool,
38    mut on_line: impl FnMut(&str),
39) {
40    for line in BufReader::new(reader).lines() {
41        match line {
42            Ok(line) => {
43                on_line(&line);
44                if !*truncated {
45                    if buffer.len() + line.len() + 1 > MAX_CAPTURE_BYTES {
46                        *truncated = true;
47                    } else {
48                        buffer.push_str(&line);
49                        buffer.push('\n');
50                    }
51                }
52            }
53            Err(_) => break,
54        }
55    }
56}
57
58/// Run a shell command, capture its output, and return the result.
59///
60/// Output is captured up to `MAX_CAPTURE_BYTES`. The `on_stdout` and
61/// `on_stderr` callbacks are called for each line as it arrives (for
62/// real-time terminal streaming).
63pub fn run_and_capture(
64    cmd: &str,
65    cwd: &Path,
66    mut on_stdout: impl FnMut(&str),
67    mut on_stderr: impl FnMut(&str),
68) -> Result<CapturedOutput, String> {
69    let mut child = Command::new("bash")
70        .arg("-c")
71        .arg(cmd)
72        .current_dir(cwd)
73        .stdout(Stdio::piped())
74        .stderr(Stdio::piped())
75        .spawn()
76        .map_err(|e| format!("bash error: {e}"))?;
77
78    let mut captured = String::new();
79    let mut truncated = false;
80
81    if let Some(stdout) = child.stdout.take() {
82        capture_lines(stdout, &mut captured, &mut truncated, &mut on_stdout);
83    }
84
85    if let Some(stderr) = child.stderr.take() {
86        capture_lines(stderr, &mut captured, &mut truncated, &mut on_stderr);
87    }
88
89    let exit_code = child.wait().ok().and_then(|s| s.code());
90
91    Ok(CapturedOutput {
92        text: captured,
93        truncated,
94        exit_code,
95    })
96}
97
98/// Build a context injection message from captured shell output.
99///
100/// Returns `None` if the captured text is empty (nothing to inject).
101pub fn build_context_message(cmd: &str, output: &CapturedOutput) -> Option<Message> {
102    if output.text.is_empty() {
103        return None;
104    }
105
106    let suffix = if output.truncated {
107        "\n[output truncated at 50KB]"
108    } else {
109        ""
110    };
111    let context_text = format!("[Shell output from: {cmd}]\n{}{suffix}", output.text);
112
113    Some(Message::User(UserMessage {
114        uuid: Uuid::new_v4(),
115        timestamp: chrono::Utc::now().to_rfc3339(),
116        content: vec![ContentBlock::Text { text: context_text }],
117        is_meta: true,
118        is_compact_summary: false,
119    }))
120}
121
122// ---------------------------------------------------------------------------
123// Unit tests
124// ---------------------------------------------------------------------------
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use std::io::Cursor;
130
131    // ── capture_lines tests ──────────────────────────────────────────
132
133    #[test]
134    fn captures_all_lines_under_limit() {
135        let input = "line one\nline two\nline three\n";
136        let reader = Cursor::new(input);
137        let mut buffer = String::new();
138        let mut truncated = false;
139        let mut printed = Vec::new();
140
141        capture_lines(reader, &mut buffer, &mut truncated, |line| {
142            printed.push(line.to_string());
143        });
144
145        assert_eq!(buffer, "line one\nline two\nline three\n");
146        assert!(!truncated);
147        assert_eq!(printed, vec!["line one", "line two", "line three"]);
148    }
149
150    #[test]
151    fn truncates_at_limit() {
152        // Create input that exceeds MAX_CAPTURE_BYTES.
153        let long_line = "x".repeat(1024);
154        let lines: Vec<String> = (0..60).map(|_| long_line.clone()).collect();
155        let input = lines.join("\n") + "\n";
156        let reader = Cursor::new(input);
157        let mut buffer = String::new();
158        let mut truncated = false;
159
160        capture_lines(reader, &mut buffer, &mut truncated, |_| {});
161
162        assert!(truncated);
163        assert!(buffer.len() <= MAX_CAPTURE_BYTES);
164        // Should have captured some lines but not all 60.
165        let captured_lines: Vec<&str> = buffer.lines().collect();
166        assert!(captured_lines.len() < 60);
167        assert!(!captured_lines.is_empty());
168    }
169
170    #[test]
171    fn empty_input_produces_empty_buffer() {
172        let reader = Cursor::new("");
173        let mut buffer = String::new();
174        let mut truncated = false;
175
176        capture_lines(reader, &mut buffer, &mut truncated, |_| {});
177
178        assert!(buffer.is_empty());
179        assert!(!truncated);
180    }
181
182    #[test]
183    fn single_line_no_newline() {
184        let reader = Cursor::new("just one line");
185        let mut buffer = String::new();
186        let mut truncated = false;
187
188        capture_lines(reader, &mut buffer, &mut truncated, |_| {});
189
190        assert_eq!(buffer, "just one line\n");
191        assert!(!truncated);
192    }
193
194    #[test]
195    fn on_line_called_for_every_line_even_after_truncation() {
196        // Verify that the callback is always called (for terminal streaming)
197        // even after truncation kicks in.
198        let long_line = "x".repeat(MAX_CAPTURE_BYTES + 100);
199        let input = format!("{long_line}\nsecond line\n");
200        let reader = Cursor::new(input);
201        let mut buffer = String::new();
202        let mut truncated = false;
203        let mut callback_count = 0;
204
205        capture_lines(reader, &mut buffer, &mut truncated, |_| {
206            callback_count += 1;
207        });
208
209        // Both lines should trigger the callback for terminal streaming.
210        assert_eq!(callback_count, 2);
211        assert!(truncated);
212    }
213
214    #[test]
215    fn truncation_boundary_exact() {
216        // Fill buffer to exactly MAX_CAPTURE_BYTES - 1, then add a line
217        // that would push it over.
218        let fill_len = MAX_CAPTURE_BYTES - 10;
219        let fill = "a".repeat(fill_len);
220        let overflow = "b".repeat(20);
221        let input = format!("{fill}\n{overflow}\n");
222        let reader = Cursor::new(input);
223        let mut buffer = String::new();
224        let mut truncated = false;
225
226        capture_lines(reader, &mut buffer, &mut truncated, |_| {});
227
228        // First line should be captured (fill_len + 1 newline = MAX - 9).
229        // Second line (20 + 1) would exceed MAX, so truncation should fire.
230        assert!(truncated);
231        assert!(buffer.contains(&fill));
232        assert!(!buffer.contains(&overflow));
233    }
234
235    // ── build_context_message tests ──────────────────────────────────
236
237    #[test]
238    fn builds_message_with_header() {
239        let output = CapturedOutput {
240            text: "hello world\n".to_string(),
241            truncated: false,
242            exit_code: Some(0),
243        };
244
245        let msg = build_context_message("echo hello", &output).unwrap();
246        if let Message::User(u) = &msg {
247            assert!(u.is_meta);
248            assert!(!u.is_compact_summary);
249            assert_eq!(u.content.len(), 1);
250            if let ContentBlock::Text { text } = &u.content[0] {
251                assert!(text.starts_with("[Shell output from: echo hello]\n"));
252                assert!(text.contains("hello world"));
253                assert!(!text.contains("[output truncated"));
254            } else {
255                panic!("Expected Text block");
256            }
257        } else {
258            panic!("Expected User message");
259        }
260    }
261
262    #[test]
263    fn builds_message_with_truncation_suffix() {
264        let output = CapturedOutput {
265            text: "partial output\n".to_string(),
266            truncated: true,
267            exit_code: Some(0),
268        };
269
270        let msg = build_context_message("big-cmd", &output).unwrap();
271        if let Message::User(u) = &msg {
272            if let ContentBlock::Text { text } = &u.content[0] {
273                assert!(text.contains("[output truncated at 50KB]"));
274                assert!(text.starts_with("[Shell output from: big-cmd]\n"));
275            } else {
276                panic!("Expected Text block");
277            }
278        } else {
279            panic!("Expected User message");
280        }
281    }
282
283    #[test]
284    fn empty_output_returns_none() {
285        let output = CapturedOutput {
286            text: String::new(),
287            truncated: false,
288            exit_code: Some(0),
289        };
290
291        assert!(build_context_message("true", &output).is_none());
292    }
293
294    #[test]
295    fn preserves_multiline_output() {
296        let output = CapturedOutput {
297            text: "line 1\nline 2\nline 3\n".to_string(),
298            truncated: false,
299            exit_code: Some(0),
300        };
301
302        let msg = build_context_message("seq 3", &output).unwrap();
303        if let Message::User(u) = &msg {
304            if let ContentBlock::Text { text } = &u.content[0] {
305                assert!(text.contains("line 1\nline 2\nline 3\n"));
306            } else {
307                panic!("Expected Text block");
308            }
309        } else {
310            panic!("Expected User message");
311        }
312    }
313
314    #[test]
315    fn special_characters_in_command_preserved() {
316        let output = CapturedOutput {
317            text: "ok\n".to_string(),
318            truncated: false,
319            exit_code: Some(0),
320        };
321
322        let msg = build_context_message("grep -r 'foo bar' .", &output).unwrap();
323        if let Message::User(u) = &msg {
324            if let ContentBlock::Text { text } = &u.content[0] {
325                assert!(text.contains("[Shell output from: grep -r 'foo bar' .]"));
326            } else {
327                panic!("Expected Text block");
328            }
329        } else {
330            panic!("Expected User message");
331        }
332    }
333
334    // ── run_and_capture tests (real subprocess) ─────────────────────
335
336    #[test]
337    fn captures_stdout_from_echo() {
338        let dir = std::env::temp_dir();
339        let result = run_and_capture("echo hello_e2e", &dir, |_| {}, |_| {}).unwrap();
340
341        assert_eq!(result.text, "hello_e2e\n");
342        assert!(!result.truncated);
343        assert_eq!(result.exit_code, Some(0));
344    }
345
346    #[test]
347    fn captures_stderr() {
348        let dir = std::env::temp_dir();
349        let result = run_and_capture("echo err_msg >&2", &dir, |_| {}, |_| {}).unwrap();
350
351        assert!(result.text.contains("err_msg"));
352        assert!(!result.truncated);
353    }
354
355    #[test]
356    fn captures_mixed_stdout_stderr() {
357        let dir = std::env::temp_dir();
358        let result = run_and_capture(
359            "echo stdout_line && echo stderr_line >&2",
360            &dir,
361            |_| {},
362            |_| {},
363        )
364        .unwrap();
365
366        assert!(result.text.contains("stdout_line"));
367        assert!(result.text.contains("stderr_line"));
368    }
369
370    #[test]
371    fn captures_multiline_output() {
372        let dir = std::env::temp_dir();
373        let result = run_and_capture("printf 'a\\nb\\nc\\n'", &dir, |_| {}, |_| {}).unwrap();
374
375        assert_eq!(result.text, "a\nb\nc\n");
376        assert!(!result.truncated);
377    }
378
379    #[test]
380    fn handles_empty_output_command() {
381        let dir = std::env::temp_dir();
382        let result = run_and_capture("true", &dir, |_| {}, |_| {}).unwrap();
383
384        assert!(result.text.is_empty());
385        assert!(!result.truncated);
386        assert_eq!(result.exit_code, Some(0));
387    }
388
389    #[test]
390    fn captures_nonzero_exit_code() {
391        let dir = std::env::temp_dir();
392        let result = run_and_capture("exit 42", &dir, |_| {}, |_| {}).unwrap();
393
394        assert_eq!(result.exit_code, Some(42));
395    }
396
397    #[test]
398    fn respects_cwd() {
399        let tmp = tempfile::tempdir().unwrap();
400        std::fs::write(tmp.path().join("marker.txt"), "found_it").unwrap();
401
402        let result = run_and_capture("cat marker.txt", tmp.path(), |_| {}, |_| {}).unwrap();
403
404        assert!(result.text.contains("found_it"));
405    }
406
407    #[test]
408    fn callbacks_receive_all_lines() {
409        let dir = std::env::temp_dir();
410        let mut stdout_lines = Vec::new();
411        let mut stderr_lines = Vec::new();
412
413        let _ = run_and_capture(
414            "echo out1 && echo out2 && echo err1 >&2",
415            &dir,
416            |line| stdout_lines.push(line.to_string()),
417            |line| stderr_lines.push(line.to_string()),
418        );
419
420        assert_eq!(stdout_lines, vec!["out1", "out2"]);
421        assert_eq!(stderr_lines, vec!["err1"]);
422    }
423
424    #[test]
425    fn truncates_large_output() {
426        let dir = std::env::temp_dir();
427        // Generate >50KB of output: 1000 lines of 100 chars each = 100KB.
428        let result = run_and_capture(
429            "for i in $(seq 1 1000); do printf '%0100d\\n' $i; done",
430            &dir,
431            |_| {},
432            |_| {},
433        )
434        .unwrap();
435
436        assert!(result.truncated);
437        assert!(result.text.len() <= MAX_CAPTURE_BYTES);
438        // Should have captured some but not all 1000 lines.
439        let line_count = result.text.lines().count();
440        assert!(line_count > 100);
441        assert!(line_count < 1000);
442    }
443
444    #[test]
445    fn invalid_command_returns_error_output() {
446        let dir = std::env::temp_dir();
447        let result = run_and_capture(
448            "nonexistent_command_xyz123 2>&1 || true",
449            &dir,
450            |_| {},
451            |_| {},
452        )
453        .unwrap();
454
455        // The command fails but bash itself runs fine.
456        // stderr should contain the error message.
457        assert!(
458            result.text.contains("not found") || result.text.contains("No such file"),
459            "Expected error message, got: {}",
460            result.text
461        );
462    }
463
464    // ── End-to-end: run_and_capture + build_context_message ──────────
465
466    #[test]
467    fn full_pipeline_echo_to_context_message() {
468        let dir = std::env::temp_dir();
469        let result = run_and_capture("echo integration_test_marker", &dir, |_| {}, |_| {}).unwrap();
470        let msg = build_context_message("echo integration_test_marker", &result).unwrap();
471
472        if let Message::User(u) = &msg {
473            assert!(u.is_meta);
474            if let ContentBlock::Text { text } = &u.content[0] {
475                assert!(text.starts_with("[Shell output from: echo integration_test_marker]"));
476                assert!(text.contains("integration_test_marker"));
477            } else {
478                panic!("Expected Text block");
479            }
480        } else {
481            panic!("Expected User message");
482        }
483    }
484
485    #[test]
486    fn full_pipeline_empty_command_no_message() {
487        let dir = std::env::temp_dir();
488        let result = run_and_capture("true", &dir, |_| {}, |_| {}).unwrap();
489        let msg = build_context_message("true", &result);
490
491        assert!(msg.is_none());
492    }
493
494    #[test]
495    fn full_pipeline_truncated_output_has_suffix() {
496        let dir = std::env::temp_dir();
497        let result = run_and_capture(
498            "for i in $(seq 1 2000); do printf '%0100d\\n' $i; done",
499            &dir,
500            |_| {},
501            |_| {},
502        )
503        .unwrap();
504
505        assert!(result.truncated);
506
507        let msg = build_context_message("big-output", &result).unwrap();
508        if let Message::User(u) = &msg {
509            if let ContentBlock::Text { text } = &u.content[0] {
510                assert!(text.contains("[output truncated at 50KB]"));
511                assert!(text.starts_with("[Shell output from: big-output]"));
512            } else {
513                panic!("Expected Text block");
514            }
515        } else {
516            panic!("Expected User message");
517        }
518    }
519}