1use serde_json::json;
2use std::io::{BufRead, BufReader};
3use std::path::Path;
4
5use lash_core::{ToolCall, ToolDefinition, ToolResult, ToolRetryPolicy, ToolScheduling};
6
7use lash_tool_support::{
8 StaticToolExecute, StaticToolProvider, object_schema, parse_optional_usize_arg, require_str,
9 run_blocking_value,
10};
11
12#[derive(Default)]
14pub struct ReadFile;
15
16pub fn read_file_provider() -> StaticToolProvider<ReadFile> {
18 StaticToolProvider::new(vec![read_file_tool_definition()], ReadFile)
19}
20
21const DEFAULT_LIMIT: usize = 2000;
22const MAX_LINE_LEN: usize = 2000;
23const MAX_OUTPUT_BYTES: usize = 50 * 1024;
24const MAX_OUTPUT_BYTES_LABEL: &str = "50 KB";
25
26struct ImageAttachmentData {
27 data: Vec<u8>,
28 media_type: lash_core::MediaType,
29 width: Option<u32>,
30 height: Option<u32>,
31 label: String,
32}
33
34enum ReadFileBlockingResult {
35 Tool(ToolResult),
36 Image(ImageAttachmentData),
37}
38
39impl ReadFileBlockingResult {
40 fn tool(result: ToolResult) -> Self {
41 Self::Tool(result)
42 }
43
44 async fn into_tool_result(self, context: &lash_core::ToolContext<'_>) -> ToolResult {
45 match self {
46 Self::Tool(result) => result,
47 Self::Image(image) => store_image_attachment(context, image).await,
48 }
49 }
50}
51
52#[async_trait::async_trait]
53impl StaticToolExecute for ReadFile {
54 async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
55 let args = call.args;
56 let path_str = match require_str(args, "path") {
57 Ok(s) => s.to_string(),
58 Err(e) => return e,
59 };
60
61 let offset = args
62 .get("offset")
63 .and_then(|v| v.as_u64())
64 .map(|v| v as usize)
65 .unwrap_or(1)
66 .max(1);
67
68 let limit = match parse_limit(args) {
69 Ok(limit) => limit,
70 Err(e) => return e,
71 };
72
73 match run_blocking_value(move || execute_read_file_sync(&path_str, offset, limit)).await {
74 Ok(result) => result.into_tool_result(call.context).await,
75 Err(err) => ToolResult::err_fmt(format_args!("{err}")),
76 }
77 }
78}
79
80fn read_file_tool_definition() -> ToolDefinition {
81 ToolDefinition::raw(
82 "tool:read_file",
83 "read_file",
84 "Read a file. Text returns lines prefixed as `LINE: text`, PDFs return extracted text, and images return visual content. Default: 2000 lines. Use `ls` for directories.",
85 object_schema(
86 serde_json::json!({
87 "path": { "type": "string" },
88 "offset": {
89 "type": "integer",
90 "minimum": 1,
91 "description": "Line offset to start reading from (1-based)"
92 },
93 "limit": {
94 "type": "integer",
95 "minimum": 1,
96 "default": DEFAULT_LIMIT,
97 "description": "Maximum lines to read (default: 2000)."
98 }
99 }),
100 &["path"],
101 ),
102 serde_json::json!({ "type": "string" }),
103 )
104 .with_examples(vec![
105 r#"await files.read({ path: "Cargo.toml" })?"#.into(),
106 r#"await files.read({ path: "src/main.rs", offset: 1, limit: 120 })?"#.into(),
107 ])
108 .with_lashlang_binding(lash_tool_support::lashlang_binding(
109 ["files"],
110 "read",
111 &["cat", "view_file"],
112 ))
113 .with_scheduling(ToolScheduling::Parallel)
114 .with_retry_policy(ToolRetryPolicy::safe(2, 25, 100))
115}
116
117fn parse_limit(args: &serde_json::Value) -> Result<usize, ToolResult> {
118 Ok(
119 parse_optional_usize_arg(args, "limit", Some(DEFAULT_LIMIT), false, 1)?
120 .unwrap_or(DEFAULT_LIMIT),
121 )
122}
123
124fn execute_read_file_sync(path_str: &str, offset: usize, limit: usize) -> ReadFileBlockingResult {
125 let path = Path::new(path_str);
126 if !path.exists() {
127 return ReadFileBlockingResult::tool(ToolResult::err_fmt(format_args!(
128 "Path does not exist: {path_str}. Use `ls` or `glob` to locate the correct path."
129 )));
130 }
131
132 if path.is_dir() {
134 let mut output = match list_directory(path, offset, limit).into_done_output() {
135 Ok(output) => output,
136 Err(_) => {
137 return ReadFileBlockingResult::tool(ToolResult::err_fmt(format_args!(
138 "directory listing unexpectedly returned pending output"
139 )));
140 }
141 };
142 if output.is_success()
143 && let lash_core::ToolCallOutcome::Success(lash_core::ToolValue::String(s)) =
144 &mut output.outcome
145 {
146 s.insert_str(0, "(Hint: use `ls` for directory listings.)\n");
147 }
148 return ReadFileBlockingResult::tool(ToolResult::from_output(output));
149 }
150
151 if let Some(mime) = image_mime(path) {
153 return read_image(path, path_str, mime);
154 }
155
156 if path
158 .extension()
159 .and_then(|e| e.to_str())
160 .map(|e| e.eq_ignore_ascii_case("pdf"))
161 .unwrap_or(false)
162 {
163 return ReadFileBlockingResult::tool(read_pdf(path, path_str, offset, limit));
164 }
165
166 if is_likely_binary(path) {
168 return ReadFileBlockingResult::tool(ToolResult::err_fmt(format_args!(
169 "Binary file detected: {path_str}. Use image-aware reads for images, or `shell.exec` for binary inspection."
170 )));
171 }
172
173 let file = match std::fs::File::open(path) {
174 Ok(file) => file,
175 Err(e) => {
176 return ReadFileBlockingResult::tool(ToolResult::err_fmt(format_args!(
177 "Failed to open file: {e}"
178 )));
179 }
180 };
181 let reader = BufReader::new(file);
182 let slice = match collect_window(
183 reader.lines(),
184 offset,
185 limit,
186 |line_no, line| format!("{line_no}: {line}"),
187 "file",
188 ) {
189 Ok(slice) => slice,
190 Err(err) => return ReadFileBlockingResult::tool(err),
191 };
192
193 ReadFileBlockingResult::tool(ToolResult::ok(json!(render_window(
194 &slice,
195 WindowKind::Lines
196 ))))
197}
198
199fn list_directory(path: &Path, offset: usize, limit: usize) -> ToolResult {
200 match std::fs::read_dir(path) {
201 Ok(entries) => {
202 let mut items: Vec<String> = Vec::new();
203 for entry in entries.flatten() {
204 let name = entry.file_name().to_string_lossy().to_string();
205 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
206 if is_dir {
207 items.push(format!("{}/", name));
208 } else {
209 items.push(name);
210 }
211 }
212 items.sort();
213 let slice = match collect_window(
214 items.into_iter().map(Ok::<String, std::io::Error>),
215 offset,
216 limit,
217 |_index, entry| entry.to_string(),
218 "directory",
219 ) {
220 Ok(slice) => slice,
221 Err(err) => return err,
222 };
223 ToolResult::ok(json!(render_window(&slice, WindowKind::Entries)))
224 }
225 Err(e) => ToolResult::err_fmt(format_args!("Failed to read directory: {e}")),
226 }
227}
228
229fn is_likely_binary(path: &Path) -> bool {
231 use std::io::Read;
232 let mut file = match std::fs::File::open(path) {
233 Ok(f) => f,
234 Err(_) => return false,
235 };
236 let mut buf = [0u8; 8192];
237 let n = match file.read(&mut buf) {
238 Ok(n) => n,
239 Err(_) => return false,
240 };
241 buf[..n].contains(&0)
242}
243
244fn image_mime(path: &Path) -> Option<&'static str> {
246 let ext = path.extension()?.to_str()?.to_ascii_lowercase();
247 match ext.as_str() {
248 "png" => Some("image/png"),
249 "jpg" | "jpeg" => Some("image/jpeg"),
250 "gif" => Some("image/gif"),
251 "webp" => Some("image/webp"),
252 "bmp" => Some("image/bmp"),
253 _ => None,
254 }
255}
256
257fn read_image(path: &Path, path_str: &str, mime: &str) -> ReadFileBlockingResult {
260 let data = match std::fs::read(path) {
261 Ok(d) => d,
262 Err(e) => {
263 return ReadFileBlockingResult::tool(ToolResult::err_fmt(format_args!(
264 "Failed to read image: {e}"
265 )));
266 }
267 };
268
269 let size_kb = data.len() / 1024;
270 let dims = image_dimensions(&data, mime);
271 let label = match dims {
272 Some((w, h)) => format!("{} ({}KB {}x{})", path_str, size_kb, w, h),
273 None => format!("{} ({}KB)", path_str, size_kb),
274 };
275
276 let Some(media_type) = lash_core::MediaType::from_mime(mime) else {
277 return ReadFileBlockingResult::tool(ToolResult::err_fmt(format_args!(
278 "Unsupported image MIME type: {mime}"
279 )));
280 };
281 ReadFileBlockingResult::Image(ImageAttachmentData {
282 data,
283 media_type,
284 width: dims.map(|(width, _)| width),
285 height: dims.map(|(_, height)| height),
286 label,
287 })
288}
289
290async fn store_image_attachment(
291 context: &lash_core::ToolContext<'_>,
292 image: ImageAttachmentData,
293) -> ToolResult {
294 let reference = match context
295 .attachments()
296 .put(
297 image.data,
298 lash_core::AttachmentCreateMeta::new(
299 image.media_type,
300 image.width,
301 image.height,
302 Some(image.label),
303 ),
304 )
305 .await
306 {
307 Ok(reference) => reference,
308 Err(err) => {
309 return ToolResult::err_fmt(format_args!("Failed to store image attachment: {err}"));
310 }
311 };
312 ToolResult::from_output(lash_core::ToolCallOutput::success(
313 lash_core::ToolValue::Attachment(reference),
314 ))
315}
316
317fn read_pdf(path: &Path, path_str: &str, offset: usize, limit: usize) -> ToolResult {
319 let pdf_bytes = match std::fs::read(path) {
320 Ok(b) => b,
321 Err(e) => return ToolResult::err_fmt(format_args!("Failed to read PDF: {e}")),
322 };
323
324 let file_size_kb = pdf_bytes.len() / 1024;
325
326 let text = match pdf_extract::extract_text_from_mem(&pdf_bytes) {
327 Ok(t) => t,
328 Err(e) => {
329 return ToolResult::err_fmt(format_args!(
330 "Failed to extract text from PDF {path_str}: {e}"
331 ));
332 }
333 };
334
335 let slice = match collect_window(
336 text.lines()
337 .map(|line| Ok::<String, std::io::Error>(line.to_string())),
338 offset,
339 limit,
340 |line_no, line| format!("{line_no}: {line}"),
341 "PDF",
342 ) {
343 Ok(slice) => slice,
344 Err(err) => return err,
345 };
346
347 let mut formatted = render_window(&slice, WindowKind::Lines);
348
349 let header = format!(
350 "[PDF: {} ({}KB, {} lines extracted)]\n",
351 path_str, file_size_kb, slice.total_items
352 );
353 formatted.insert_str(0, &header);
354
355 ToolResult::ok(json!(formatted))
356}
357
358fn image_dimensions(data: &[u8], mime: &str) -> Option<(u32, u32)> {
360 match mime {
361 "image/png" => png_dimensions(data),
362 "image/jpeg" => jpeg_dimensions(data),
363 "image/gif" => gif_dimensions(data),
364 _ => None,
365 }
366}
367
368fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> {
370 if data.len() < 24 {
371 return None;
372 }
373 if &data[..8] != b"\x89PNG\r\n\x1a\n" {
375 return None;
376 }
377 let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
378 let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
379 Some((w, h))
380}
381
382fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
384 let mut i = 0;
385 while i + 1 < data.len() {
386 if data[i] != 0xFF {
387 i += 1;
388 continue;
389 }
390 let marker = data[i + 1];
391 if marker == 0xC0 || marker == 0xC2 {
393 if i + 9 >= data.len() {
394 return None;
395 }
396 let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
397 let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
398 return Some((w, h));
399 }
400 if marker == 0xD8 || marker == 0xD9 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
402 i += 2;
403 } else if i + 3 < data.len() {
404 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
405 i += 2 + len;
406 } else {
407 break;
408 }
409 }
410 None
411}
412
413fn gif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
415 if data.len() < 10 {
416 return None;
417 }
418 if &data[..3] != b"GIF" {
420 return None;
421 }
422 let w = u16::from_le_bytes([data[6], data[7]]) as u32;
423 let h = u16::from_le_bytes([data[8], data[9]]) as u32;
424 Some((w, h))
425}
426
427struct WindowSlice {
428 rendered: Vec<String>,
429 total_items: usize,
430 shown_start: Option<usize>,
431 shown_end: Option<usize>,
432 has_more_items: bool,
433 truncated_by_bytes: bool,
434}
435
436enum WindowKind {
437 Lines,
438 Entries,
439}
440
441fn collect_window<I, E, F>(
442 items: I,
443 offset: usize,
444 limit: usize,
445 mut format_item: F,
446 item_label: &str,
447) -> Result<WindowSlice, ToolResult>
448where
449 I: IntoIterator<Item = Result<String, E>>,
450 E: std::fmt::Display,
451 F: FnMut(usize, &str) -> String,
452{
453 let mut total_items = 0usize;
454 let mut bytes = 0usize;
455 let mut rendered = Vec::new();
456 let mut has_more_items = false;
457 let mut truncated_by_bytes = false;
458
459 for item in items {
460 let item = item.map_err(|err| {
461 ToolResult::err_fmt(format_args!("Failed to read {item_label}: {err}"))
462 })?;
463 total_items += 1;
464 if total_items < offset {
465 continue;
466 }
467 if rendered.len() >= limit {
468 has_more_items = true;
469 continue;
470 }
471
472 let item = truncate_line(&item);
473 let rendered_item = format_item(total_items, &item);
474 let size = rendered_item.len() + usize::from(!rendered.is_empty());
475 if bytes + size > MAX_OUTPUT_BYTES {
476 truncated_by_bytes = true;
477 has_more_items = true;
478 break;
479 }
480 bytes += size;
481 rendered.push(rendered_item);
482 }
483
484 if total_items < offset && !(total_items == 0 && offset == 1) {
485 return Err(ToolResult::err_fmt(format_args!(
486 "Offset {offset} is out of range for this {item_label} ({total_items} items)"
487 )));
488 }
489
490 let shown_start = (!rendered.is_empty()).then_some(offset);
491 let shown_end = shown_start.map(|start| start + rendered.len().saturating_sub(1));
492
493 Ok(WindowSlice {
494 rendered,
495 total_items,
496 shown_start,
497 shown_end,
498 has_more_items,
499 truncated_by_bytes,
500 })
501}
502
503fn render_window(slice: &WindowSlice, kind: WindowKind) -> String {
504 let mut output = slice.rendered.join("\n");
505 let Some(shown_start) = slice.shown_start else {
506 return output;
507 };
508 let Some(shown_end) = slice.shown_end else {
509 return output;
510 };
511
512 let next_offset = shown_end + 1;
513 match kind {
514 WindowKind::Lines => {
515 if slice.truncated_by_bytes {
516 output.push_str(&format!(
517 "\n[output capped at {}. Showing lines {}-{}. Use offset={} to continue.]",
518 MAX_OUTPUT_BYTES_LABEL, shown_start, shown_end, next_offset
519 ));
520 } else if slice.has_more_items {
521 output.push_str(&format!(
522 "\n[results truncated: showing lines {}-{} of {}. Use offset={} to continue.]",
523 shown_start, shown_end, slice.total_items, next_offset
524 ));
525 }
526 }
527 WindowKind::Entries => {
528 if slice.truncated_by_bytes {
529 output.push_str(&format!(
530 "\n[output capped at {}. Showing entries {}-{}. Use offset={} to continue.]",
531 MAX_OUTPUT_BYTES_LABEL, shown_start, shown_end, next_offset
532 ));
533 } else if slice.has_more_items {
534 output.push_str(&format!(
535 "\n[results truncated: showing entries {}-{} of {}. Use offset={} to continue.]",
536 shown_start, shown_end, slice.total_items, next_offset
537 ));
538 }
539 }
540 }
541 output
542}
543
544fn truncate_line(line: &str) -> String {
545 if line.len() > MAX_LINE_LEN {
546 let end = floor_char_boundary(line, MAX_LINE_LEN);
549 format!("{}...", &line[..end])
550 } else {
551 line.to_string()
552 }
553}
554
555fn floor_char_boundary(text: &str, index: usize) -> usize {
556 if index >= text.len() {
557 return text.len();
558 }
559 let mut idx = index;
560 while idx > 0 && !text.is_char_boundary(idx) {
561 idx -= 1;
562 }
563 idx
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use std::sync::Arc;
570
571 use lash_core::AttachmentStore;
572 use serde_json::json;
573 use tempfile::TempDir;
574
575 #[tokio::test]
576 async fn test_read_file() {
577 let dir = TempDir::new().unwrap();
578 let path = dir.path().join("test.txt");
579 std::fs::write(&path, "line1\nline2\nline3").unwrap();
580 let result = lash_core::testing::run_tool(
581 &read_file_provider(),
582 "read_file",
583 &json!({"path": path.to_str().unwrap()}),
584 )
585 .await;
586 assert!(result.is_success());
587 let value = result.value_for_projection();
588 let text = value.as_str().unwrap();
589 assert!(text.contains("1: line1"));
590 assert!(text.contains("2: line2"));
591 assert!(text.contains("3: line3"));
592 assert!(!text.contains('|'));
593 }
594
595 #[tokio::test]
596 async fn test_read_with_offset_and_limit() {
597 let dir = TempDir::new().unwrap();
598 let path = dir.path().join("test.txt");
599 std::fs::write(&path, "line1\nline2\nline3\nline4\nline5").unwrap();
600 let result = lash_core::testing::run_tool(
601 &read_file_provider(),
602 "read_file",
603 &json!({"path": path.to_str().unwrap(), "offset": 2, "limit": 2}),
604 )
605 .await;
606 assert!(result.is_success());
607 let value = result.value_for_projection();
608 let text = value.as_str().unwrap();
609 assert!(text.contains("2: line2"));
610 assert!(text.contains("3: line3"));
611 assert!(!text.contains("1: line1"));
612 assert!(!text.contains("4: line4"));
613 assert!(text.contains("results truncated"));
614 assert!(text.contains("offset=4"));
615 }
616
617 #[tokio::test]
618 async fn test_read_caps_large_output_by_bytes() {
619 let dir = TempDir::new().unwrap();
620 let path = dir.path().join("test.txt");
621 let content = (0..200)
622 .map(|idx| format!("{idx}: {}", "x".repeat(400)))
623 .collect::<Vec<_>>()
624 .join("\n");
625 std::fs::write(&path, content).unwrap();
626 let result = lash_core::testing::run_tool(
627 &read_file_provider(),
628 "read_file",
629 &json!({"path": path.to_str().unwrap(), "limit": 200}),
630 )
631 .await;
632 assert!(result.is_success());
633 let value = result.value_for_projection();
634 let text = value.as_str().unwrap();
635 assert!(text.contains("output capped at 50 KB"));
636 assert!(text.contains("Use offset="));
637 }
638
639 #[tokio::test]
640 async fn test_read_nonexistent() {
641 let result = lash_core::testing::run_tool(
642 &read_file_provider(),
643 "read_file",
644 &json!({"path": "/nonexistent/path/to/file.txt"}),
645 )
646 .await;
647 assert!(!result.is_success());
648 }
649
650 #[test]
653 fn test_png_dimensions_valid() {
654 let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
656 data.extend_from_slice(&[0, 0, 0, 13]);
658 data.extend_from_slice(b"IHDR");
660 data.extend_from_slice(&640u32.to_be_bytes());
662 data.extend_from_slice(&480u32.to_be_bytes());
664 let (w, h) = png_dimensions(&data).unwrap();
665 assert_eq!((w, h), (640, 480));
666 }
667
668 #[test]
669 fn test_png_dimensions_truncated() {
670 assert!(png_dimensions(&[0x89, b'P', b'N', b'G']).is_none());
671 }
672
673 #[test]
674 fn test_png_dimensions_wrong_sig() {
675 let data = vec![0; 24];
676 assert!(png_dimensions(&data).is_none());
677 }
678
679 #[test]
682 fn test_jpeg_dimensions_valid() {
683 let mut data = vec![0xFF, 0xD8]; data.extend_from_slice(&[0xFF, 0xC0]);
687 data.extend_from_slice(&[0x00, 0x11]);
689 data.push(8);
691 data.extend_from_slice(&480u16.to_be_bytes());
693 data.extend_from_slice(&640u16.to_be_bytes());
695 data.push(0);
697 let (w, h) = jpeg_dimensions(&data).unwrap();
698 assert_eq!((w, h), (640, 480));
699 }
700
701 #[test]
702 fn test_jpeg_dimensions_truncated() {
703 assert!(jpeg_dimensions(&[0xFF, 0xD8, 0xFF, 0xC0]).is_none());
704 }
705
706 #[test]
709 fn test_gif87a_dimensions() {
710 let mut data = b"GIF87a".to_vec();
711 data.extend_from_slice(&320u16.to_le_bytes());
713 data.extend_from_slice(&200u16.to_le_bytes());
715 let (w, h) = gif_dimensions(&data).unwrap();
716 assert_eq!((w, h), (320, 200));
717 }
718
719 #[test]
720 fn test_gif89a_dimensions() {
721 let mut data = b"GIF89a".to_vec();
722 data.extend_from_slice(&100u16.to_le_bytes());
723 data.extend_from_slice(&50u16.to_le_bytes());
724 let (w, h) = gif_dimensions(&data).unwrap();
725 assert_eq!((w, h), (100, 50));
726 }
727
728 #[test]
729 fn test_gif_bad_signature() {
730 let data = b"NOT_GIF___".to_vec();
731 assert!(gif_dimensions(&data).is_none());
732 }
733
734 #[test]
737 fn test_image_mime() {
738 assert_eq!(image_mime(Path::new("photo.png")), Some("image/png"));
739 assert_eq!(image_mime(Path::new("photo.jpg")), Some("image/jpeg"));
740 assert_eq!(image_mime(Path::new("photo.jpeg")), Some("image/jpeg"));
741 assert_eq!(image_mime(Path::new("anim.gif")), Some("image/gif"));
742 assert_eq!(image_mime(Path::new("photo.webp")), Some("image/webp"));
743 assert_eq!(image_mime(Path::new("photo.bmp")), Some("image/bmp"));
744 assert_eq!(image_mime(Path::new("file.txt")), None);
745 assert_eq!(image_mime(Path::new("noext")), None);
746 }
747
748 #[tokio::test]
749 async fn test_read_image_returns_attachment_value() {
750 let dir = TempDir::new().unwrap();
751 let path = dir.path().join("tiny.png");
752 let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
753 data.extend_from_slice(&[0, 0, 0, 13]);
754 data.extend_from_slice(b"IHDR");
755 data.extend_from_slice(&1u32.to_be_bytes());
756 data.extend_from_slice(&1u32.to_be_bytes());
757 std::fs::write(&path, &data).unwrap();
758
759 let store = Arc::new(lash_core::InMemoryAttachmentStore::new());
760 let host = Arc::new(lash_core::testing::MockSessionManager::default());
761 let context = lash_core::ToolContext::__for_testing(
762 "test-session".into(),
763 host.clone(),
764 host.clone(),
765 host,
766 Arc::new(lash_core::UnavailableProcessService),
767 store.clone(),
768 lash_core::DirectCompletionClient::from_fn(|_, _| {
769 Err(lash_core::PluginError::Session(
770 "direct completions are unavailable in read_file tests".to_string(),
771 ))
772 }),
773 None,
774 );
775 let result = ReadFile
776 .execute(lash_core::ToolCall {
777 name: "read_file",
778 args: &json!({"path": path.to_str().unwrap()}),
779 context: &context,
780 progress: None,
781 })
782 .await;
783
784 let lash_core::ToolCallOutcome::Success(lash_core::ToolValue::Attachment(reference)) =
785 result.into_done_output().expect("read_file result").outcome
786 else {
787 panic!("expected attachment result");
788 };
789 assert_eq!(reference.byte_len, data.len() as u64);
790 assert_eq!(reference.width, Some(1));
791 assert_eq!(reference.height, Some(1));
792 assert_eq!(store.get(&reference.id).await.unwrap().bytes, data);
793 }
794
795 #[test]
796 fn truncate_line_does_not_split_multibyte_char() {
797 let mut line = "a".repeat(MAX_LINE_LEN - 1);
801 line.push('€');
802 line.push_str(&"b".repeat(50));
803 assert!(line.len() > MAX_LINE_LEN);
804
805 let truncated = truncate_line(&line);
806
807 assert!(truncated.ends_with("..."));
809 let body = truncated.strip_suffix("...").unwrap();
810 assert_eq!(body, "a".repeat(MAX_LINE_LEN - 1));
812 assert!(body.is_char_boundary(body.len()));
813 }
814}