1use crate::llm::ContentSource;
2use crate::reminders::{append_reminder, builtin};
3use crate::{Environment, PrimitiveToolName, Tool, ToolContext, ToolResult, ToolTier};
4use anyhow::{Context, Result};
5use base64::Engine;
6use serde::Deserialize;
7use serde_json::{Value, json};
8use std::path::Path;
9use std::sync::Arc;
10
11use super::PrimitiveToolContext;
12
13const MAX_TOKENS: usize = 25_000;
15const CHARS_PER_TOKEN: usize = 4;
16
17pub struct ReadTool<E: Environment> {
19 ctx: PrimitiveToolContext<E>,
20}
21
22impl<E: Environment> ReadTool<E> {
23 #[must_use]
24 pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
25 Self {
26 ctx: PrimitiveToolContext::new(environment, capabilities),
27 }
28 }
29}
30
31#[derive(Debug, Deserialize)]
32struct ReadInput {
33 #[serde(alias = "file_path")]
35 path: String,
36 #[serde(
39 default,
40 deserialize_with = "super::deserialize_optional_usize_from_string_or_int"
41 )]
42 offset: Option<usize>,
43 #[serde(
46 default,
47 deserialize_with = "super::deserialize_optional_usize_from_string_or_int"
48 )]
49 limit: Option<usize>,
50}
51
52enum ReadContent {
53 Text(String),
54 NativeBinary { mime_type: &'static str },
55 UnsupportedBinary,
56}
57
58impl<E: Environment + 'static> Tool<()> for ReadTool<E> {
59 type Name = PrimitiveToolName;
60
61 fn name(&self) -> PrimitiveToolName {
62 PrimitiveToolName::Read
63 }
64
65 fn display_name(&self) -> &'static str {
66 "Read File"
67 }
68
69 fn description(&self) -> &'static str {
70 "Read text files directly, and attach supported images/PDFs for native model inspection. Can optionally specify offset and limit for text files."
71 }
72
73 fn tier(&self) -> ToolTier {
74 ToolTier::Observe
75 }
76
77 fn input_schema(&self) -> Value {
78 json!({
79 "type": "object",
80 "properties": {
81 "path": {
82 "type": "string",
83 "description": "Path to the file to read"
84 },
85 "offset": {
86 "anyOf": [
87 {"type": "integer"},
88 {"type": "string", "pattern": "^[0-9]+$"}
89 ],
90 "description": "Line number to start from (1-based). Accepts either an integer or a numeric string. Optional. Only applies to text files."
91 },
92 "limit": {
93 "anyOf": [
94 {"type": "integer"},
95 {"type": "string", "pattern": "^[0-9]+$"}
96 ],
97 "description": "Number of lines to read. Accepts either an integer or a numeric string. Optional. Only applies to text files."
98 }
99 },
100 "required": ["path"]
101 })
102 }
103
104 async fn execute(&self, _ctx: &ToolContext<()>, input: Value) -> Result<ToolResult> {
105 let input: ReadInput = serde_json::from_value(input.clone())
106 .with_context(|| format!("Invalid input for read tool: {input}"))?;
107
108 let path = self.ctx.environment.resolve_path(&input.path);
109
110 if !self.ctx.capabilities.can_read(&path) {
111 return Ok(ToolResult::error(format!(
112 "Permission denied: cannot read '{path}'"
113 )));
114 }
115
116 let exists = self
117 .ctx
118 .environment
119 .exists(&path)
120 .await
121 .context("Failed to check file existence")?;
122
123 if !exists {
124 return Ok(ToolResult::error(format!("File not found: '{path}'")));
125 }
126
127 let is_dir = self
128 .ctx
129 .environment
130 .is_dir(&path)
131 .await
132 .context("Failed to check if path is directory")?;
133
134 if is_dir {
135 return Ok(ToolResult::error(format!(
136 "'{path}' is a directory, not a file"
137 )));
138 }
139
140 let bytes = self
141 .ctx
142 .environment
143 .read_file_bytes(&path)
144 .await
145 .context("Failed to read file")?;
146
147 let mut result = match classify_content(&path, &bytes) {
148 ReadContent::Text(content) => {
149 read_text_content(&path, &content, input.offset, input.limit)
150 }
151 ReadContent::NativeBinary { mime_type } => {
152 if input.offset.is_some() || input.limit.is_some() {
153 ToolResult::error(format!(
154 "offset and limit are only supported for text files. '{path}' is a {mime_type} file."
155 ))
156 } else {
157 ToolResult::success(format!(
158 "Attached '{path}' ({mime_type}, {} bytes) for native model inspection.",
159 bytes.len()
160 ))
161 .with_documents(vec![ContentSource::new(
162 mime_type,
163 base64::engine::general_purpose::STANDARD.encode(&bytes),
164 )])
165 }
166 }
167 ReadContent::UnsupportedBinary => ToolResult::error(format!(
168 "'{path}' is a binary file in an unsupported format. The read tool currently supports text files, images (PNG/JPEG/GIF/WebP), and PDF documents."
169 )),
170 };
171
172 if result.success && result.output == "(empty file)" {
173 append_reminder(&mut result, builtin::READ_EMPTY_FILE_REMINDER);
174 }
175
176 if result.success {
177 append_reminder(&mut result, builtin::READ_SECURITY_REMINDER);
178 }
179
180 Ok(result)
181 }
182}
183
184fn read_text_content(
185 path: &str,
186 content: &str,
187 offset: Option<usize>,
188 limit: Option<usize>,
189) -> ToolResult {
190 let lines: Vec<&str> = content.lines().collect();
191 let total_lines = lines.len();
192 let offset = offset.unwrap_or(1).saturating_sub(1);
193 let selected_lines: Vec<&str> = lines.iter().copied().skip(offset).collect();
194
195 let limit = if let Some(user_limit) = limit {
196 user_limit
197 } else {
198 let selected_content_len: usize = selected_lines.iter().map(|line| line.len() + 1).sum();
199 let estimated_tokens = selected_content_len / CHARS_PER_TOKEN;
200
201 if estimated_tokens > MAX_TOKENS {
202 let suggested_limit = estimate_lines_for_tokens(&selected_lines, MAX_TOKENS);
203 return ToolResult::success(format!(
204 "File too large to read at once (~{estimated_tokens} tokens, max {MAX_TOKENS}).\n\
205 Total lines: {total_lines}\n\n\
206 Use 'offset' and 'limit' parameters to read specific portions.\n\
207 Suggested: Start with offset=1, limit={suggested_limit} to read the first ~{MAX_TOKENS} tokens.\n\n\
208 Example: {{\"path\": \"{path}\", \"offset\": 1, \"limit\": {suggested_limit}}}"
209 ));
210 }
211
212 selected_lines.len()
213 };
214
215 let selected_lines: Vec<String> = lines
216 .into_iter()
217 .skip(offset)
218 .take(limit)
219 .enumerate()
220 .map(|(i, line)| format!("{:>6}\t{}", offset + i + 1, line))
221 .collect();
222
223 let is_empty = selected_lines.is_empty();
224 let output = if is_empty {
225 "(empty file)".to_string()
226 } else {
227 let header = if offset > 0 || limit < total_lines {
228 format!(
229 "Showing lines {}-{} of {} total\n",
230 offset + 1,
231 (offset + selected_lines.len()).min(total_lines),
232 total_lines
233 )
234 } else {
235 String::new()
236 };
237 format!("{header}{}", selected_lines.join("\n"))
238 };
239
240 ToolResult::success(output)
241}
242
243fn classify_content(path: &str, bytes: &[u8]) -> ReadContent {
244 if let Some(mime_type) = detect_native_binary_mime(path, bytes) {
245 return ReadContent::NativeBinary { mime_type };
246 }
247
248 if let Ok(content) = std::str::from_utf8(bytes) {
249 return ReadContent::Text(content.to_string());
250 }
251
252 ReadContent::UnsupportedBinary
253}
254
255fn detect_native_binary_mime(path: &str, bytes: &[u8]) -> Option<&'static str> {
256 if bytes.starts_with(b"%PDF-") {
257 return Some("application/pdf");
258 }
259
260 if bytes.starts_with(&[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']) {
261 return Some("image/png");
262 }
263
264 if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
265 return Some("image/jpeg");
266 }
267
268 if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
269 return Some("image/gif");
270 }
271
272 if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
273 return Some("image/webp");
274 }
275
276 let extension = Path::new(path)
277 .extension()
278 .and_then(|ext| ext.to_str())
279 .map(str::to_ascii_lowercase);
280
281 match extension.as_deref() {
282 Some("pdf") => Some("application/pdf"),
283 Some("png") => Some("image/png"),
284 Some("jpg" | "jpeg") => Some("image/jpeg"),
285 Some("gif") => Some("image/gif"),
286 Some("webp") => Some("image/webp"),
287 _ => None,
288 }
289}
290
291fn estimate_lines_for_tokens(lines: &[&str], max_tokens: usize) -> usize {
293 let max_chars = max_tokens * CHARS_PER_TOKEN;
294 let mut total_chars = 0;
295 let mut line_count = 0;
296
297 for line in lines {
298 let line_chars = line.len() + 1;
299 if total_chars + line_chars > max_chars {
300 break;
301 }
302 total_chars += line_chars;
303 line_count += 1;
304 }
305
306 line_count.max(1)
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::{AgentCapabilities, InMemoryFileSystem};
313
314 fn create_test_tool(
315 fs: Arc<InMemoryFileSystem>,
316 capabilities: AgentCapabilities,
317 ) -> ReadTool<InMemoryFileSystem> {
318 ReadTool::new(fs, capabilities)
319 }
320
321 fn tool_ctx() -> ToolContext<()> {
322 ToolContext::new(())
323 }
324
325 #[tokio::test]
326 async fn test_read_entire_file() -> anyhow::Result<()> {
327 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
328 fs.write_file("test.txt", "line 1\nline 2\nline 3").await?;
329
330 let tool = create_test_tool(fs, AgentCapabilities::full_access());
331 let result = tool
332 .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
333 .await?;
334
335 assert!(result.success);
336 assert!(result.output.contains("line 1"));
337 assert!(result.output.contains("line 2"));
338 assert!(result.output.contains("line 3"));
339 assert!(result.documents.is_empty());
340 Ok(())
341 }
342
343 #[tokio::test]
344 async fn test_read_with_offset() -> anyhow::Result<()> {
345 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
346 fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
347 .await?;
348
349 let tool = create_test_tool(fs, AgentCapabilities::full_access());
350 let result = tool
351 .execute(
352 &tool_ctx(),
353 json!({"path": "/workspace/test.txt", "offset": 3}),
354 )
355 .await?;
356
357 assert!(result.success);
358 assert!(result.output.contains("Showing lines 3-5 of 5 total"));
359 assert!(result.output.contains("line 3"));
360 assert!(result.output.contains("line 4"));
361 assert!(result.output.contains("line 5"));
362 assert!(!result.output.contains("\tline 1"));
363 assert!(!result.output.contains("\tline 2"));
364 Ok(())
365 }
366
367 #[tokio::test]
368 async fn test_read_with_limit() -> anyhow::Result<()> {
369 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
370 fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
371 .await?;
372
373 let tool = create_test_tool(fs, AgentCapabilities::full_access());
374 let result = tool
375 .execute(
376 &tool_ctx(),
377 json!({"path": "/workspace/test.txt", "limit": 2}),
378 )
379 .await?;
380
381 assert!(result.success);
382 assert!(result.output.contains("Showing lines 1-2 of 5 total"));
383 assert!(result.output.contains("line 1"));
384 assert!(result.output.contains("line 2"));
385 assert!(!result.output.contains("\tline 3"));
386 Ok(())
387 }
388
389 #[tokio::test]
390 async fn test_read_with_offset_and_limit() -> anyhow::Result<()> {
391 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
392 fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
393 .await?;
394
395 let tool = create_test_tool(fs, AgentCapabilities::full_access());
396 let result = tool
397 .execute(
398 &tool_ctx(),
399 json!({"path": "/workspace/test.txt", "offset": 2, "limit": 2}),
400 )
401 .await?;
402
403 assert!(result.success);
404 assert!(result.output.contains("Showing lines 2-3 of 5 total"));
405 assert!(result.output.contains("line 2"));
406 assert!(result.output.contains("line 3"));
407 assert!(!result.output.contains("\tline 1"));
408 assert!(!result.output.contains("\tline 4"));
409 Ok(())
410 }
411
412 #[tokio::test]
413 async fn test_read_with_string_offset_and_limit() -> anyhow::Result<()> {
414 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
415 fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
416 .await?;
417
418 let tool = create_test_tool(fs, AgentCapabilities::full_access());
419 let result = tool
420 .execute(
421 &tool_ctx(),
422 json!({"path": "/workspace/test.txt", "offset": "2", "limit": "2"}),
423 )
424 .await?;
425
426 assert!(result.success);
427 assert!(result.output.contains("Showing lines 2-3 of 5 total"));
428 assert!(result.output.contains("line 2"));
429 assert!(result.output.contains("line 3"));
430 assert!(!result.output.contains("\tline 1"));
431 assert!(!result.output.contains("\tline 4"));
432 Ok(())
433 }
434
435 #[tokio::test]
436 async fn test_read_nonexistent_file() -> anyhow::Result<()> {
437 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
438
439 let tool = create_test_tool(fs, AgentCapabilities::full_access());
440 let result = tool
441 .execute(&tool_ctx(), json!({"path": "/workspace/nonexistent.txt"}))
442 .await?;
443
444 assert!(!result.success);
445 assert!(result.output.contains("File not found"));
446 Ok(())
447 }
448
449 #[tokio::test]
450 async fn test_read_directory_returns_error() -> anyhow::Result<()> {
451 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
452 fs.create_dir("/workspace/subdir").await?;
453
454 let tool = create_test_tool(fs, AgentCapabilities::full_access());
455 let result = tool
456 .execute(&tool_ctx(), json!({"path": "/workspace/subdir"}))
457 .await?;
458
459 assert!(!result.success);
460 assert!(result.output.contains("is a directory"));
461 Ok(())
462 }
463
464 #[tokio::test]
465 async fn test_read_permission_denied() -> anyhow::Result<()> {
466 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
467 fs.write_file("secret.txt", "secret content").await?;
468
469 let tool = create_test_tool(fs, AgentCapabilities::none());
470 let result = tool
471 .execute(&tool_ctx(), json!({"path": "/workspace/secret.txt"}))
472 .await?;
473
474 assert!(!result.success);
475 assert!(result.output.contains("Permission denied"));
476 Ok(())
477 }
478
479 #[tokio::test]
480 async fn test_read_denied_path_via_capabilities() -> anyhow::Result<()> {
481 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
482 fs.write_file("secrets/api_key.txt", "API_KEY=secret")
483 .await?;
484
485 let caps =
486 AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/secrets/**".into()]);
487
488 let tool = create_test_tool(fs, caps);
489 let result = tool
490 .execute(
491 &tool_ctx(),
492 json!({"path": "/workspace/secrets/api_key.txt"}),
493 )
494 .await?;
495
496 assert!(!result.success);
497 assert!(result.output.contains("Permission denied"));
498 Ok(())
499 }
500
501 #[tokio::test]
502 async fn test_read_allowed_path_restriction() -> anyhow::Result<()> {
503 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
504 fs.write_file("src/main.rs", "fn main() {} ").await?;
505 fs.write_file("config/settings.toml", "key = value").await?;
506
507 let caps = AgentCapabilities::read_only()
508 .with_denied_paths(vec![])
509 .with_allowed_paths(vec!["/workspace/src/**".into()]);
510
511 let tool = create_test_tool(Arc::clone(&fs), caps.clone());
512
513 let result = tool
514 .execute(&tool_ctx(), json!({"path": "/workspace/src/main.rs"}))
515 .await?;
516 assert!(result.success);
517
518 let tool = create_test_tool(fs, caps);
519 let result = tool
520 .execute(
521 &tool_ctx(),
522 json!({"path": "/workspace/config/settings.toml"}),
523 )
524 .await?;
525 assert!(!result.success);
526 assert!(result.output.contains("Permission denied"));
527 Ok(())
528 }
529
530 #[tokio::test]
531 async fn test_read_empty_file() -> anyhow::Result<()> {
532 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
533 fs.write_file("empty.txt", "").await?;
534
535 let tool = create_test_tool(fs, AgentCapabilities::full_access());
536 let result = tool
537 .execute(&tool_ctx(), json!({"path": "/workspace/empty.txt"}))
538 .await?;
539
540 assert!(result.success);
541 assert!(result.output.contains("(empty file)"));
542 Ok(())
543 }
544
545 #[tokio::test]
546 async fn test_read_large_file_with_pagination() -> anyhow::Result<()> {
547 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
548 let content: String = (1..=100)
549 .map(|i| format!("line {i}"))
550 .collect::<Vec<_>>()
551 .join("\n");
552 fs.write_file("large.txt", &content).await?;
553
554 let tool = create_test_tool(fs, AgentCapabilities::full_access());
555 let result = tool
556 .execute(
557 &tool_ctx(),
558 json!({"path": "/workspace/large.txt", "offset": 50, "limit": 10}),
559 )
560 .await?;
561
562 assert!(result.success);
563 assert!(result.output.contains("Showing lines 50-59 of 100 total"));
564 assert!(result.output.contains("line 50"));
565 assert!(result.output.contains("line 59"));
566 assert!(!result.output.contains("\tline 49"));
567 assert!(!result.output.contains("\tline 60"));
568 Ok(())
569 }
570
571 #[tokio::test]
572 async fn test_read_offset_beyond_file_length() -> anyhow::Result<()> {
573 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
574 fs.write_file("short.txt", "line 1\nline 2").await?;
575
576 let tool = create_test_tool(fs, AgentCapabilities::full_access());
577 let result = tool
578 .execute(
579 &tool_ctx(),
580 json!({"path": "/workspace/short.txt", "offset": 100}),
581 )
582 .await?;
583
584 assert!(result.success);
585 assert!(result.output.contains("(empty file)"));
586 Ok(())
587 }
588
589 #[tokio::test]
590 async fn test_read_file_with_special_characters() -> anyhow::Result<()> {
591 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
592 let content = "特殊字符\néàü\n🎉emoji\ntab\there";
593 fs.write_file("special.txt", content).await?;
594
595 let tool = create_test_tool(fs, AgentCapabilities::full_access());
596 let result = tool
597 .execute(&tool_ctx(), json!({"path": "/workspace/special.txt"}))
598 .await?;
599
600 assert!(result.success);
601 assert!(result.output.contains("特殊字符"));
602 assert!(result.output.contains("éàü"));
603 assert!(result.output.contains("🎉emoji"));
604 Ok(())
605 }
606
607 #[tokio::test]
608 async fn test_read_image_file_attaches_native_content() -> anyhow::Result<()> {
609 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
610 let png = vec![
611 0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n', 1, 2, 3, 4,
612 ];
613 fs.write_file_bytes("image.png", &png).await?;
614
615 let tool = create_test_tool(fs, AgentCapabilities::full_access());
616 let result = tool
617 .execute(&tool_ctx(), json!({"path": "/workspace/image.png"}))
618 .await?;
619
620 assert!(result.success);
621 assert!(result.output.contains("Attached '/workspace/image.png'"));
622 assert_eq!(result.documents.len(), 1);
623 assert_eq!(result.documents[0].media_type, "image/png");
624 Ok(())
625 }
626
627 #[tokio::test]
628 async fn test_read_pdf_file_attaches_native_content() -> anyhow::Result<()> {
629 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
630 fs.write_file_bytes("doc.pdf", b"%PDF-1.7\nbody").await?;
631
632 let tool = create_test_tool(fs, AgentCapabilities::full_access());
633 let result = tool
634 .execute(&tool_ctx(), json!({"path": "/workspace/doc.pdf"}))
635 .await?;
636
637 assert!(result.success);
638 assert_eq!(result.documents.len(), 1);
639 assert_eq!(result.documents[0].media_type, "application/pdf");
640 Ok(())
641 }
642
643 #[tokio::test]
644 async fn test_read_binary_with_offset_returns_error() -> anyhow::Result<()> {
645 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
646 fs.write_file_bytes("doc.pdf", b"%PDF-1.7\nbody").await?;
647
648 let tool = create_test_tool(fs, AgentCapabilities::full_access());
649 let result = tool
650 .execute(
651 &tool_ctx(),
652 json!({"path": "/workspace/doc.pdf", "offset": 1}),
653 )
654 .await?;
655
656 assert!(!result.success);
657 assert!(result.output.contains("only supported for text files"));
658 Ok(())
659 }
660
661 #[tokio::test]
662 async fn test_read_unsupported_binary_returns_error() -> anyhow::Result<()> {
663 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
664 fs.write_file_bytes("archive.bin", &[0, 159, 146, 150])
665 .await?;
666
667 let tool = create_test_tool(fs, AgentCapabilities::full_access());
668 let result = tool
669 .execute(&tool_ctx(), json!({"path": "/workspace/archive.bin"}))
670 .await?;
671
672 assert!(!result.success);
673 assert!(result.output.contains("unsupported format"));
674 Ok(())
675 }
676
677 #[tokio::test]
678 async fn test_read_tool_metadata() {
679 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
680 let tool = create_test_tool(fs, AgentCapabilities::full_access());
681
682 assert_eq!(tool.name(), PrimitiveToolName::Read);
683 assert_eq!(tool.tier(), ToolTier::Observe);
684 assert!(tool.description().contains("Read"));
685
686 let schema = tool.input_schema();
687 assert!(schema.get("properties").is_some());
688 assert!(schema["properties"].get("path").is_some());
689 }
690
691 #[tokio::test]
692 async fn test_read_invalid_input() -> anyhow::Result<()> {
693 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
694 let tool = create_test_tool(fs, AgentCapabilities::full_access());
695
696 let result = tool.execute(&tool_ctx(), json!({})).await;
697
698 assert!(result.is_err());
699 Ok(())
700 }
701
702 #[tokio::test]
703 async fn test_read_large_file_exceeds_token_limit() -> anyhow::Result<()> {
704 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
705 let line = "x".repeat(100);
706 let content: String = (1..=1500)
707 .map(|i| format!("{i}: {line}"))
708 .collect::<Vec<_>>()
709 .join("\n");
710 fs.write_file("huge.txt", &content).await?;
711
712 let tool = create_test_tool(fs, AgentCapabilities::full_access());
713 let result = tool
714 .execute(&tool_ctx(), json!({"path": "/workspace/huge.txt"}))
715 .await?;
716
717 assert!(result.success);
718 assert!(result.output.contains("File too large to read at once"));
719 assert!(result.output.contains("Total lines: 1500"));
720 assert!(result.output.contains("offset"));
721 assert!(result.output.contains("limit"));
722 Ok(())
723 }
724
725 #[tokio::test]
726 async fn test_read_large_file_with_explicit_limit_bypasses_check() -> anyhow::Result<()> {
727 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
728 let line = "x".repeat(100);
729 let content: String = (1..=1500)
730 .map(|i| format!("{i}: {line}"))
731 .collect::<Vec<_>>()
732 .join("\n");
733 fs.write_file("huge.txt", &content).await?;
734
735 let tool = create_test_tool(fs, AgentCapabilities::full_access());
736 let result = tool
737 .execute(
738 &tool_ctx(),
739 json!({"path": "/workspace/huge.txt", "offset": 1, "limit": 10}),
740 )
741 .await?;
742
743 assert!(result.success);
744 assert!(result.output.contains("Showing lines 1-10 of 1500 total"));
745 assert!(!result.output.contains("File too large"));
746 Ok(())
747 }
748
749 #[test]
750 fn test_estimate_lines_for_tokens() {
751 let long = "x".repeat(100);
752 let lines: Vec<&str> = vec!["short line", "another short line", &long];
753
754 let count = estimate_lines_for_tokens(&lines, 10);
755 assert_eq!(count, 2);
756
757 let count = estimate_lines_for_tokens(&lines, 1);
758 assert_eq!(count, 1);
759 }
760}