Skip to main content

agent_engine/tools/
read.rs

1use super::{resolve_path_in, Tool, ToolContext, ToolOutput};
2use crate::{Result, RuntimeError};
3use serde_json::{json, Value};
4
5/// Raw-byte cap. 3.5 MiB × 4/3 base64 = 4.89 MB < Anthropic's 5 MB/image.
6pub(crate) const MAX_IMAGE_BYTES: usize = 3_670_016;
7/// Anthropic rejects any side > 8000 px.
8const MAX_IMAGE_SIDE_PX: u32 = 8000;
9/// Above this long edge Anthropic downscales server-side (coordinate caveat).
10const PROVIDER_DOWNSCALE_LONG_EDGE: u32 = 1568;
11/// Seatbelt: refuse to load anything larger than this into memory at all.
12/// Checked via `fs::metadata` BEFORE the read, so a 200 MB `.tif` or a
13/// device file never gets pulled in.
14pub(crate) const MAX_READ_BYTES: u64 = 64 * 1024 * 1024;
15
16pub struct ReadTool;
17
18#[async_trait::async_trait]
19impl Tool for ReadTool {
20    fn effect(&self) -> crate::tools::catalog::ToolEffect {
21        crate::tools::catalog::ToolEffect::ReadOnly
22    }
23
24    fn origin(&self) -> crate::tools::ToolOrigin {
25        crate::tools::ToolOrigin::Builtin
26    }
27
28    fn name(&self) -> &str {
29        "read"
30    }
31
32    fn description(&self) -> &str {
33        "Read the contents of a file. Returns lines with line numbers. Reads up to 500 lines by default. For large files, use offset and limit to read in sections. Image files (PNG, JPEG, GIF, WebP) are returned as an image the model can see, up to 3.5 MB."
34    }
35
36    fn parameters(&self) -> Value {
37        json!({
38            "type": "object",
39            "properties": {
40                "path": {
41                    "type": "string",
42                    "description": "Path to the file to read"
43                },
44                "offset": {
45                    "type": "integer",
46                    "description": "Line number to start reading from (0-indexed, default: 0)"
47                },
48                "limit": {
49                    "type": "integer",
50                    "description": "Maximum number of lines to read (default: all lines)"
51                }
52            },
53            "required": ["path"]
54        })
55    }
56
57    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
58        self.execute_rich(params, ctx)
59            .await
60            .map(ToolOutput::into_summary)
61    }
62
63    async fn execute_rich(&self, params: Value, ctx: ToolContext) -> Result<ToolOutput> {
64        let raw_path = params["path"]
65            .as_str()
66            .ok_or_else(|| RuntimeError::Tool("Missing path parameter".to_string()))?;
67        let path = resolve_path_in(raw_path, ctx.capabilities.cwd.as_deref());
68
69        // Size guard BEFORE reading: one stat, no bytes loaded.
70        let meta = tokio::fs::metadata(&path).await.map_err(|e| {
71            RuntimeError::Tool(format!("Failed to read file '{}': {}", path.display(), e))
72        })?;
73        if !meta.is_file() {
74            return Err(RuntimeError::Tool(format!(
75                "'{}' is not a regular file. Use `bash` (ls, file, head) to inspect it.",
76                path.display()
77            )));
78        }
79        if meta.len() > MAX_READ_BYTES {
80            return Err(RuntimeError::Tool(format!(
81                "File '{}' is {} KB; the read tool refuses files over {} KB. Use `bash` with `head`, `tail`, `xxd`, or `sed -n` to read a slice, or shrink an image with `convert`.",
82                path.display(),
83                meta.len().div_ceil(1024),
84                MAX_READ_BYTES / 1024
85            )));
86        }
87
88        // Read raw bytes first to detect binary files
89        let bytes = tokio::fs::read(&path).await.map_err(|e| {
90            RuntimeError::Tool(format!("Failed to read file '{}': {}", path.display(), e))
91        })?;
92
93        if let Some(mime) = sniff_image_mime(&bytes) {
94            return image_output(&path, mime, &bytes);
95        }
96
97        let content = match String::from_utf8(bytes) {
98            Ok(s) => s,
99            Err(_) => return Err(RuntimeError::Tool(format!(
100                "File '{}' appears to be binary (not valid UTF-8). Use `bash` with `xxd` or `file` to inspect binary files. Image files (png/jpg/gif/webp) are returned as images automatically.",
101                path.display()
102            ))),
103        };
104
105        let lines: Vec<&str> = content.lines().collect();
106        let total_lines = lines.len();
107
108        let offset = params["offset"].as_u64().unwrap_or(0) as usize;
109        let limit = params["limit"]
110            .as_u64()
111            .map(|l| l as usize)
112            .unwrap_or(500.min(total_lines));
113
114        let start = offset.min(total_lines);
115        let end = (start + limit).min(total_lines);
116
117        let mut result = String::new();
118        for (i, line) in lines[start..end].iter().enumerate() {
119            result.push_str(&format!("{}\t{}\n", start + i + 1, line));
120        }
121
122        if total_lines > end {
123            result.push_str(&format!("\n... ({} more lines)", total_lines - end));
124        }
125
126        Ok(ToolOutput::Text(result))
127    }
128}
129
130/// Magic-byte sniff — content, never extension.
131pub(crate) fn sniff_image_mime(b: &[u8]) -> Option<&'static str> {
132    if b.len() >= 8 && b[..8] == [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] {
133        return Some("image/png");
134    }
135    if b.len() >= 3 && b[..3] == [0xFF, 0xD8, 0xFF] {
136        return Some("image/jpeg");
137    }
138    if b.len() >= 6 && (&b[..6] == b"GIF87a" || &b[..6] == b"GIF89a") {
139        return Some("image/gif");
140    }
141    if b.len() >= 12 && &b[..4] == b"RIFF" && &b[8..12] == b"WEBP" {
142        return Some("image/webp");
143    }
144    None
145}
146
147/// Header-only dimension parse. `None` → `image_output` rejects the file
148/// (an unsized image bypasses the 8000 px guard → provider 400 → poison pill).
149pub(crate) fn image_dimensions(mime: &str, b: &[u8]) -> Option<(u32, u32)> {
150    match mime {
151        "image/png" if b.len() >= 24 => Some((be32(&b[16..20]), be32(&b[20..24]))),
152        "image/gif" if b.len() >= 10 => Some((le16(&b[6..8]) as u32, le16(&b[8..10]) as u32)),
153        "image/webp" if b.len() >= 30 => match &b[12..16] {
154            b"VP8 " => Some((
155                (le16(&b[26..28]) & 0x3FFF) as u32,
156                (le16(&b[28..30]) & 0x3FFF) as u32,
157            )),
158            b"VP8L" => {
159                let x = [b[21], b[22], b[23], b[24]];
160                Some((
161                    1 + (((x[1] & 0x3F) as u32) << 8 | x[0] as u32),
162                    1 + (((x[3] & 0x0F) as u32) << 10
163                        | (x[2] as u32) << 2
164                        | ((x[1] & 0xC0) as u32) >> 6),
165                ))
166            }
167            b"VP8X" => Some((1 + le24(&b[24..27]), 1 + le24(&b[27..30]))),
168            _ => None,
169        },
170        "image/jpeg" => jpeg_dimensions(b),
171        _ => None,
172    }
173}
174
175fn jpeg_dimensions(b: &[u8]) -> Option<(u32, u32)> {
176    // Walk segments from offset 2 until a SOFn marker (C0–CF except C4, C8, CC).
177    let mut i = 2usize;
178    while i + 4 <= b.len() {
179        if b[i] != 0xFF {
180            return None;
181        }
182        let m = b[i + 1];
183        if m == 0xFF {
184            i += 1; // fill byte
185            continue;
186        }
187        if m == 0xD8 || m == 0x01 || (0xD0..=0xD7).contains(&m) {
188            i += 2; // standalone marker
189            continue;
190        }
191        if m == 0xD9 || m == 0xDA {
192            return None; // EOI / SOS: no SOF found
193        }
194        let len = be16(&b[i + 2..i + 4]) as usize;
195        if matches!(m, 0xC0..=0xCF) && !matches!(m, 0xC4 | 0xC8 | 0xCC) {
196            if i + 9 > b.len() {
197                return None;
198            }
199            // SOFn payload: Lf(2) P(1) Y(2) X(2) — height then width.
200            return Some((be16(&b[i + 7..i + 9]) as u32, be16(&b[i + 5..i + 7]) as u32));
201        }
202        i += 2 + len;
203    }
204    None
205}
206
207fn be16(b: &[u8]) -> u16 {
208    u16::from_be_bytes([b[0], b[1]])
209}
210fn be32(b: &[u8]) -> u32 {
211    u32::from_be_bytes([b[0], b[1], b[2], b[3]])
212}
213fn le16(b: &[u8]) -> u16 {
214    u16::from_le_bytes([b[0], b[1]])
215}
216fn le24(b: &[u8]) -> u32 {
217    u32::from_le_bytes([b[0], b[1], b[2], 0])
218}
219fn le32(b: &[u8]) -> u32 {
220    u32::from_le_bytes([b[0], b[1], b[2], b[3]])
221}
222
223/// Cheap structural integrity check — trailer/size sanity only, no decode.
224/// A truncated or corrupt image shipped to the provider is a poison pill:
225/// the 400 repeats on every request until the session is cleared, because
226/// a `tool_result` user message always survives history repair. Reject here.
227pub(crate) fn image_integrity_error(mime: &str, b: &[u8]) -> Option<&'static str> {
228    match mime {
229        "image/png" => {
230            if b.len() < 16 || &b[12..16] != b"IHDR" {
231                return Some("first chunk is not IHDR");
232            }
233            if !b.ends_with(b"IEND\xAE\x42\x60\x82") {
234                return Some("missing IEND trailer (truncated?)");
235            }
236        }
237        "image/jpeg" if !b.ends_with(&[0xFF, 0xD9]) => {
238            return Some("missing EOI marker (truncated?)");
239        }
240        "image/gif" if !b.ends_with(&[0x3B]) => {
241            return Some("missing GIF trailer (truncated?)");
242        }
243        "image/webp" => {
244            let riff = le32(&b[4..8]) as usize;
245            // RIFF size = file length − 8; allow a single pad byte of slack.
246            if riff + 8 != b.len() && riff + 9 != b.len() {
247                return Some("RIFF size does not match file length (truncated?)");
248            }
249        }
250        _ => {}
251    }
252    None
253}
254
255pub(crate) fn image_output(
256    path: &std::path::Path,
257    mime: &'static str,
258    bytes: &[u8],
259) -> Result<ToolOutput> {
260    use base64::Engine as _;
261    let kb = bytes.len().div_ceil(1024);
262    if bytes.len() > MAX_IMAGE_BYTES {
263        return Err(RuntimeError::Tool(format!(
264            "Image '{p}' is {kb} KB; the read tool accepts images up to {cap} KB. \
265             Shrink it with bash and read the new file, e.g.:\n  \
266             convert '{p}' -resize 1568x1568\\> -quality 85 /tmp/shrunk.jpg   (ImageMagick)\n  \
267             sips -Z 1568 '{p}' --out /tmp/shrunk.png                        (macOS)",
268            p = path.display(),
269            kb = kb,
270            cap = MAX_IMAGE_BYTES / 1024
271        )));
272    }
273    if let Some(why) = image_integrity_error(mime, bytes) {
274        return Err(RuntimeError::Tool(format!(
275            "Image '{}' ({mime}, {kb} KB) appears truncated or corrupt: {why}. \
276             Not sent to the model. Verify with `file` / `identify`, or re-export it and read the new file.",
277            path.display()
278        )));
279    }
280    // Unknown dims → reject. Shipping an image we cannot size means the
281    // 8000 px guard is bypassed and a provider 400 poisons the session.
282    let Some((w, h)) = image_dimensions(mime, bytes) else {
283        return Err(RuntimeError::Tool(format!(
284            "Image '{}' ({mime}, {kb} KB): could not parse dimensions from the header. \
285             Not sent to the model. Re-export it (e.g. `convert '{}' /tmp/fixed.png`) and read the new file.",
286            path.display(),
287            path.display()
288        )));
289    };
290    // Zero-sized images are invalid for every supported format and are
291    // rejected by providers — same poison-pill class as unknown dims.
292    if w == 0 || h == 0 {
293        return Err(RuntimeError::Tool(format!(
294            "Image '{}' ({mime}, {kb} KB) declares invalid dimensions {w}x{h} (zero-sized).              Not sent to the model. Re-export it and read the new file.",
295            path.display()
296        )));
297    }
298    if w > MAX_IMAGE_SIDE_PX || h > MAX_IMAGE_SIDE_PX {
299        return Err(RuntimeError::Tool(format!(
300            "Image '{}' is {w}x{h}; the provider rejects images with any side over {MAX_IMAGE_SIDE_PX} px. \
301             Downscale it with bash (e.g. `convert ... -resize 1568x1568\\>`) and read the new file.",
302            path.display()
303        )));
304    }
305    let mut summary = format!("Image: {} ({w}x{h}, {mime}, {kb} KB)", path.display());
306    if w.max(h) > PROVIDER_DOWNSCALE_LONG_EDGE {
307        summary.push_str("\nNote: long edge exceeds 1568 px; the provider downscales before viewing, so pixel coordinates read from the image are approximate.");
308    }
309    let data = base64::engine::general_purpose::STANDARD.encode(bytes);
310    Ok(ToolOutput::Blocks {
311        blocks: vec![
312            // FIRST — invariant: blocks[0] is text == summary.
313            json!({ "type": "text", "text": summary }),
314            json!({ "type": "image", "source": { "type": "base64", "media_type": mime, "data": data } }),
315        ],
316        summary,
317    })
318}
319
320#[cfg(test)]
321mod tests {
322    use super::super::test_helpers::create_tool_context;
323    use super::*;
324    use crate::tools::Tool;
325    use serde_json::json;
326
327    #[test]
328    fn test_read_tool_schema() {
329        let tool = ReadTool;
330        assert_eq!(tool.name(), "read");
331        assert!(!tool.description().is_empty());
332
333        let params = tool.parameters();
334        assert_eq!(params["type"], "object");
335        assert!(params["properties"].is_object());
336        assert!(params["required"].is_array());
337    }
338
339    #[tokio::test]
340    async fn test_read_tool_execution() {
341        let temp_dir = std::env::temp_dir();
342        let test_file = temp_dir.join("read_tool_test.txt");
343
344        // Create temp file with known content
345        let content = "line 1\nline 2\nline 3\nline 4\nline 5";
346        std::fs::write(&test_file, content).unwrap();
347
348        let tool = ReadTool;
349        let ctx = create_tool_context();
350
351        // Test basic read
352        let params = json!({
353            "path": test_file.to_string_lossy()
354        });
355        let result = tool.execute(params, ctx).await.unwrap();
356
357        // Verify line numbers and content
358        assert!(result.contains("1\tline 1"));
359        assert!(result.contains("2\tline 2"));
360        assert!(result.contains("5\tline 5"));
361
362        // Test with offset and limit
363        let ctx = create_tool_context();
364        let params = json!({
365            "path": test_file.to_string_lossy(),
366            "offset": 2,
367            "limit": 2
368        });
369        let result = tool.execute(params, ctx).await.unwrap();
370
371        assert!(result.contains("3\tline 3"));
372        assert!(result.contains("4\tline 4"));
373        assert!(!result.contains("1\tline 1"));
374        assert!(!result.contains("5\tline 5"));
375
376        // Cleanup
377        let _ = std::fs::remove_file(&test_file);
378    }
379
380    #[tokio::test]
381    async fn test_read_tool_offset() {
382        let temp_dir = std::env::temp_dir();
383        let test_file = temp_dir.join("test_read_tool_offset.txt");
384
385        // Write 10 lines
386        let lines = (1..=10).map(|i| format!("line {}", i)).collect::<Vec<_>>();
387        let content = lines.join("\n");
388        std::fs::write(&test_file, &content).unwrap();
389
390        let tool = ReadTool;
391        let ctx = create_tool_context();
392
393        // Read with offset=5 (0-indexed, so starts at line 6)
394        let params = json!({
395            "path": test_file.to_string_lossy(),
396            "offset": 5
397        });
398
399        let result = tool.execute(params, ctx).await.unwrap();
400
401        // First line shown should be line 6 (1-indexed in output)
402        assert!(result.contains("6\tline 6"));
403        // Should not contain earlier lines
404        assert!(!result.contains("1\tline 1"));
405        assert!(!result.contains("5\tline 5"));
406
407        // Cleanup
408        let _ = std::fs::remove_file(&test_file);
409    }
410
411    // ── image support ───────────────────────────────────────────────────────
412
413    const PNG_SIG: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
414
415    /// PNG signature + IHDR chunk header with the given dims (24 bytes total).
416    fn png_header(w: u32, h: u32) -> Vec<u8> {
417        let mut v = PNG_SIG.to_vec();
418        v.extend_from_slice(&13u32.to_be_bytes()); // IHDR length
419        v.extend_from_slice(b"IHDR");
420        v.extend_from_slice(&w.to_be_bytes());
421        v.extend_from_slice(&h.to_be_bytes());
422        v
423    }
424
425    const PNG_IEND: [u8; 12] = [0, 0, 0, 0, b'I', b'E', b'N', b'D', 0xAE, 0x42, 0x60, 0x82];
426
427    /// Structurally complete PNG with the given dims: IHDR + IEND, no IDAT.
428    fn png_with_dims(w: u32, h: u32) -> Vec<u8> {
429        let mut v = png_header(w, h);
430        v.extend_from_slice(&[8, 6, 0, 0, 0]); // depth, color, comp, filter, interlace
431        v.extend_from_slice(&[0, 0, 0, 0]); // crc (not validated)
432        v.extend_from_slice(&PNG_IEND);
433        v
434    }
435
436    /// Minimal 1×1 PNG.
437    fn tiny_png() -> Vec<u8> {
438        png_with_dims(1, 1)
439    }
440
441    fn tmp(name: &str, bytes: &[u8]) -> std::path::PathBuf {
442        let p = std::env::temp_dir().join(format!("read_img_{}_{name}", std::process::id()));
443        std::fs::write(&p, bytes).unwrap();
444        p
445    }
446
447    #[test]
448    fn sniff_png_jpeg_gif_webp() {
449        assert_eq!(sniff_image_mime(&PNG_SIG), Some("image/png"));
450        assert_eq!(
451            sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]),
452            Some("image/jpeg")
453        );
454        assert_eq!(
455            sniff_image_mime(b"GIF89a\x01\x00\x01\x00"),
456            Some("image/gif")
457        );
458        assert_eq!(
459            sniff_image_mime(b"GIF87a\x01\x00\x01\x00"),
460            Some("image/gif")
461        );
462        assert_eq!(
463            sniff_image_mime(b"RIFF\x00\x00\x00\x00WEBPVP8 "),
464            Some("image/webp")
465        );
466        assert_eq!(sniff_image_mime(b"hello"), None);
467        assert_eq!(
468            sniff_image_mime(b"BM\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"),
469            None
470        );
471        assert_eq!(sniff_image_mime(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3"), None);
472        assert_eq!(sniff_image_mime(b"RIFF\x00\x00\x00\x00WAVEfmt "), None);
473        assert_eq!(sniff_image_mime(b""), None);
474    }
475
476    #[tokio::test]
477    async fn sniff_ignores_extension() {
478        let text_as_png = tmp("notes.png", b"just text\nline two");
479        let out = ReadTool
480            .execute_rich(
481                json!({"path": text_as_png.to_string_lossy()}),
482                create_tool_context(),
483            )
484            .await
485            .unwrap();
486        match out {
487            ToolOutput::Text(s) => assert!(s.contains("1\tjust text"), "{s}"),
488            other => panic!("expected Text, got {other:?}"),
489        }
490
491        let png_as_txt = tmp("data.txt", &tiny_png());
492        let out = ReadTool
493            .execute_rich(
494                json!({"path": png_as_txt.to_string_lossy()}),
495                create_tool_context(),
496            )
497            .await
498            .unwrap();
499        assert!(matches!(out, ToolOutput::Blocks { .. }), "{out:?}");
500        let _ = std::fs::remove_file(text_as_png);
501        let _ = std::fs::remove_file(png_as_txt);
502    }
503
504    #[test]
505    fn png_dimensions_from_ihdr() {
506        assert_eq!(image_dimensions("image/png", &tiny_png()), Some((1, 1)));
507        assert_eq!(
508            image_dimensions("image/png", &png_header(1024, 1536)),
509            Some((1024, 1536))
510        );
511        assert_eq!(image_dimensions("image/png", &PNG_SIG), None);
512    }
513
514    #[test]
515    fn jpeg_dimensions_from_sof0() {
516        let mut j = vec![0xFF, 0xD8]; // SOI
517        j.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]); // APP0, len 16
518        j.extend_from_slice(b"JFIF\0");
519        j.extend_from_slice(&[1, 1, 0, 0, 1, 0, 1, 0, 0]); // 14 payload bytes total
520        j.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]); // SOF0, len 17, precision
521        j.extend_from_slice(&480u16.to_be_bytes()); // height
522        j.extend_from_slice(&640u16.to_be_bytes()); // width
523        j.extend_from_slice(&[3, 1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1]);
524        assert_eq!(image_dimensions("image/jpeg", &j), Some((640, 480)));
525
526        // SOI + SOS only → no SOF → None, no panic.
527        let sos_only = [0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x08, 0, 0, 0, 0, 0, 0];
528        assert_eq!(image_dimensions("image/jpeg", &sos_only), None);
529
530        // Truncated SOF header → None, no panic.
531        let trunc = [0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x01];
532        assert_eq!(image_dimensions("image/jpeg", &trunc), None);
533
534        // Garbage after SOI: deterministic pseudo-random bytes, must never panic.
535        let mut seed = 0x9E37_79B9u32;
536        for _ in 0..200 {
537            let mut buf = vec![0xFF, 0xD8, 0xFF];
538            for _ in 0..64 {
539                seed ^= seed << 13;
540                seed ^= seed >> 17;
541                seed ^= seed << 5;
542                buf.push((seed & 0xFF) as u8);
543            }
544            let _ = image_dimensions("image/jpeg", &buf);
545        }
546    }
547
548    #[test]
549    fn webp_dimensions_vp8_vp8l_vp8x() {
550        // VP8 (lossy): frame tag (3) + start code 9D 01 2A + w(2) + h(2)
551        let mut vp8 = b"RIFF\x00\x00\x00\x00WEBPVP8 \x00\x00\x00\x00".to_vec();
552        vp8.extend_from_slice(&[0x00, 0x00, 0x00, 0x9D, 0x01, 0x2A]);
553        vp8.extend_from_slice(&320u16.to_le_bytes());
554        vp8.extend_from_slice(&240u16.to_le_bytes());
555        assert_eq!(image_dimensions("image/webp", &vp8), Some((320, 240)));
556
557        // VP8L (lossless): signature 0x2F then 14-bit w-1, 14-bit h-1.
558        let (w, h) = (100u32, 50u32);
559        let bits: u32 = (w - 1) | ((h - 1) << 14);
560        let mut vp8l = b"RIFF\x00\x00\x00\x00WEBPVP8L\x00\x00\x00\x00\x2F".to_vec();
561        vp8l.extend_from_slice(&bits.to_le_bytes());
562        vp8l.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
563        assert_eq!(image_dimensions("image/webp", &vp8l), Some((100, 50)));
564
565        // VP8X (extended): flags(4) + 24-bit w-1 + 24-bit h-1.
566        let mut vp8x = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0A\x00\x00\x00".to_vec();
567        vp8x.extend_from_slice(&[0, 0, 0, 0]);
568        vp8x.extend_from_slice(&(1999u32).to_le_bytes()[..3]);
569        vp8x.extend_from_slice(&(999u32).to_le_bytes()[..3]);
570        assert_eq!(image_dimensions("image/webp", &vp8x), Some((2000, 1000)));
571    }
572
573    #[tokio::test]
574    async fn image_read_block_ordering() {
575        use base64::Engine as _;
576        let bytes = tiny_png();
577        let p = tmp("order.png", &bytes);
578        let out = ReadTool
579            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
580            .await
581            .unwrap();
582        let ToolOutput::Blocks { blocks, summary } = out else {
583            panic!("expected Blocks");
584        };
585        assert_eq!(blocks.len(), 2);
586        assert_eq!(blocks[0]["type"], "text");
587        assert_eq!(blocks[0]["text"], summary);
588        assert_eq!(blocks[1]["type"], "image");
589        assert_eq!(blocks[1]["source"]["type"], "base64");
590        assert_eq!(blocks[1]["source"]["media_type"], "image/png");
591        let decoded = base64::engine::general_purpose::STANDARD
592            .decode(blocks[1]["source"]["data"].as_str().unwrap())
593            .unwrap();
594        assert_eq!(decoded, bytes);
595        let _ = std::fs::remove_file(p);
596    }
597
598    #[tokio::test]
599    async fn image_over_cap_rejected() {
600        let mut bytes = png_header(10, 10);
601        bytes.resize(MAX_IMAGE_BYTES + 1, 0);
602        let p = tmp("big.png", &bytes);
603        let err = ReadTool
604            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
605            .await
606            .unwrap_err();
607        let RuntimeError::Tool(msg) = err else {
608            panic!("expected Tool error, got {err:?}");
609        };
610        assert!(msg.contains("convert"), "{msg}");
611        assert!(msg.contains("KB"), "{msg}");
612        assert!(msg.len() < 1024, "{}", msg.len());
613        let _ = std::fs::remove_file(p);
614    }
615
616    #[tokio::test]
617    async fn image_over_8000px_rejected() {
618        let p = tmp("wide.png", &png_with_dims(8001, 10));
619        let err = ReadTool
620            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
621            .await
622            .unwrap_err();
623        assert!(err.to_string().contains("8000"), "{err}");
624        let _ = std::fs::remove_file(p);
625    }
626
627    #[tokio::test]
628    async fn legacy_execute_returns_summary_for_images() {
629        let p = tmp("legacy.png", &tiny_png());
630        let s = ReadTool
631            .execute(json!({"path": p.to_string_lossy()}), create_tool_context())
632            .await
633            .unwrap();
634        assert!(s.starts_with("Image: "), "{s}");
635        assert!(s.len() < 512);
636        let _ = std::fs::remove_file(p);
637    }
638
639    #[tokio::test]
640    async fn label_format() {
641        let p = tmp("label.png", &tiny_png());
642        let out = ReadTool
643            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
644            .await
645            .unwrap();
646        assert_eq!(
647            out.summary(),
648            format!("Image: {} (1x1, image/png, 1 KB)", p.display())
649        );
650        let _ = std::fs::remove_file(p);
651
652        let p = tmp("wide2.png", &png_with_dims(2000, 100));
653        let out = ReadTool
654            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
655            .await
656            .unwrap();
657        assert!(out.summary().starts_with(&format!(
658            "Image: {} (2000x100, image/png, 1 KB)",
659            p.display()
660        )));
661        assert!(out.summary().contains("1568 px"), "{}", out.summary());
662        let _ = std::fs::remove_file(p);
663    }
664
665    /// Spec §5.7 scripted integration check against the real test asset.
666    /// Skips (passes) if the asset is absent so CI without ~/Jawz stays green.
667    #[tokio::test]
668    async fn real_avatar_png_round_trips_as_image_blocks() {
669        let Some(home) = std::env::var_os("HOME") else {
670            return;
671        };
672        let p = std::path::PathBuf::from(home).join("Jawz/media/jawz-avatar-v1.png");
673        if !p.exists() {
674            eprintln!(
675                "SKIPPED real_avatar_png_round_trips_as_image_blocks: {} absent",
676                p.display()
677            );
678            return;
679        }
680        let out = ReadTool
681            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
682            .await
683            .unwrap();
684        let ToolOutput::Blocks { blocks, summary } = out else {
685            panic!("expected Blocks");
686        };
687        assert!(
688            summary.starts_with(&format!(
689                "Image: {} (1024x1536, image/png, 2292 KB)",
690                p.display()
691            )),
692            "{summary}"
693        );
694        assert_eq!(blocks[0]["text"], summary);
695        assert_eq!(blocks[1]["source"]["media_type"], "image/png");
696        let data = blocks[1]["source"]["data"].as_str().unwrap();
697        assert!(data.starts_with("iVBORw0KGgo"));
698        assert!(serde_json::to_vec(&blocks[1]).unwrap().len() < 5_000_000);
699    }
700
701    /// Integration evidence: a real .rs file still reads as numbered text,
702    /// and (when `SYNAPS_TEST_BIG_IMAGE` points at a >3.5 MiB image) the
703    /// oversize path rejects in-slot with the shrink hint and no base64.
704    #[tokio::test]
705    async fn real_rs_file_and_optional_big_image() {
706        let out = ReadTool
707            .execute_rich(
708                json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/src/tools/read.rs"), "limit": 3}),
709                create_tool_context(),
710            )
711            .await
712            .unwrap();
713        let ToolOutput::Text(text) = out else {
714            panic!("expected Text")
715        };
716        assert!(text.starts_with("1\tuse super::"), "{text}");
717
718        let Ok(big) = std::env::var("SYNAPS_TEST_BIG_IMAGE") else {
719            eprintln!("SKIPPED big-image half of real_rs_file_and_optional_big_image: SYNAPS_TEST_BIG_IMAGE unset");
720            return;
721        };
722        let err = ReadTool
723            .execute_rich(json!({"path": big}), create_tool_context())
724            .await
725            .unwrap_err()
726            .to_string();
727        assert!(err.contains("accepts images up to 3584 KB"), "{err}");
728        assert!(err.contains("convert"));
729        assert!(err.len() < 1024);
730    }
731
732    // ── S2: poison-pill integrity checks ────────────────────────────────────
733
734    #[test]
735    fn integrity_check_per_format() {
736        // PNG
737        assert_eq!(image_integrity_error("image/png", &tiny_png()), None);
738        assert!(image_integrity_error("image/png", &png_header(1, 1)).is_some()); // no IEND
739        let mut bad_chunk = tiny_png();
740        bad_chunk[12..16].copy_from_slice(b"iTXt");
741        assert!(image_integrity_error("image/png", &bad_chunk).is_some());
742        assert!(image_integrity_error("image/png", &PNG_SIG).is_some());
743        // JPEG
744        assert_eq!(
745            image_integrity_error("image/jpeg", &[0xFF, 0xD8, 0xFF, 0xD9]),
746            None
747        );
748        assert!(image_integrity_error("image/jpeg", &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0]).is_some());
749        // GIF
750        assert_eq!(
751            image_integrity_error("image/gif", b"GIF89a\x01\x00\x01\x00\x3B"),
752            None
753        );
754        assert!(image_integrity_error("image/gif", b"GIF89a\x01\x00\x01\x00").is_some());
755        // WebP: RIFF size must equal len - 8.
756        let mut webp = b"RIFF\x00\x00\x00\x00WEBPVP8 ".to_vec();
757        webp.extend_from_slice(&[0u8; 20]);
758        assert!(image_integrity_error("image/webp", &webp).is_some());
759        let riff = (webp.len() - 8) as u32;
760        webp[4..8].copy_from_slice(&riff.to_le_bytes());
761        assert_eq!(image_integrity_error("image/webp", &webp), None);
762        webp.pop();
763        assert!(image_integrity_error("image/webp", &webp).is_some());
764    }
765
766    #[tokio::test]
767    async fn truncated_png_rejected_no_image_block() {
768        // Header + 3 KB of zeros, no IEND — the "half-written screenshot" case.
769        let mut bytes = png_header(10, 10);
770        bytes.resize(3_000, 0);
771        let p = tmp("trunc.png", &bytes);
772        let err = ReadTool
773            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
774            .await
775            .unwrap_err();
776        let RuntimeError::Tool(msg) = err else {
777            panic!("expected Tool error, got {err:?}");
778        };
779        assert!(msg.contains("truncated or corrupt"), "{msg}");
780        assert!(msg.contains("IEND"), "{msg}");
781        assert!(!msg.contains("iVBORw0KGgo"), "no base64 in error");
782        assert!(msg.len() < 1024);
783        // Legacy path: same rejection, plain string, no image anywhere.
784        let s = ReadTool
785            .execute(json!({"path": p.to_string_lossy()}), create_tool_context())
786            .await
787            .unwrap_err()
788            .to_string();
789        assert!(s.contains("truncated or corrupt"), "{s}");
790        let _ = std::fs::remove_file(p);
791    }
792
793    #[tokio::test]
794    async fn truncated_jpeg_and_gif_rejected() {
795        let jpeg = [
796            0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0, 1, 1, 0, 0, 1, 0, 1, 0,
797            0,
798        ];
799        let p = tmp("trunc.jpg", &jpeg);
800        let err = ReadTool
801            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
802            .await
803            .unwrap_err()
804            .to_string();
805        assert!(err.contains("EOI"), "{err}");
806        let _ = std::fs::remove_file(p);
807
808        let p = tmp("trunc.gif", b"GIF89a\x01\x00\x01\x00\x00\x00\x00");
809        let err = ReadTool
810            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
811            .await
812            .unwrap_err()
813            .to_string();
814        assert!(err.contains("GIF trailer"), "{err}");
815        let _ = std::fs::remove_file(p);
816    }
817
818    #[tokio::test]
819    async fn unparseable_dims_rejected_not_shipped() {
820        // Structurally complete JPEG (SOI … EOI) but SOS before any SOF →
821        // dims None → must NOT become an image block.
822        let jpeg = [
823            0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x08, 0, 0, 0, 0, 0, 0, 0xFF, 0xD9,
824        ];
825        let p = tmp("nodims.jpg", &jpeg);
826        let err = ReadTool
827            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
828            .await
829            .unwrap_err()
830            .to_string();
831        assert!(err.contains("could not parse dimensions"), "{err}");
832        assert!(err.contains("Not sent to the model"), "{err}");
833        let _ = std::fs::remove_file(p);
834    }
835
836    #[tokio::test]
837    async fn zero_dimension_images_rejected_not_shipped() {
838        for (name, w, h) in [
839            ("w0.png", 0u32, 7u32),
840            ("h0.png", 7, 0),
841            ("both0.png", 0, 0),
842        ] {
843            let p = tmp(name, &png_with_dims(w, h));
844            let err = ReadTool
845                .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
846                .await
847                .unwrap_err()
848                .to_string();
849            assert!(err.contains("zero-sized"), "{name}: {err}");
850            assert!(err.contains(&format!("{w}x{h}")), "{name}: {err}");
851            assert!(err.contains("Not sent to the model"), "{name}: {err}");
852            let _ = std::fs::remove_file(p);
853        }
854    }
855
856    #[tokio::test]
857    async fn complete_gif_and_jpeg_round_trip_through_execute_rich() {
858        let p = tmp("ok.gif", b"GIF89a\x02\x00\x03\x00\x00\x00\x00\x3B");
859        let out = ReadTool
860            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
861            .await
862            .unwrap();
863        assert!(
864            out.summary().contains("(2x3, image/gif, 1 KB)"),
865            "{}",
866            out.summary()
867        );
868        assert!(matches!(out, ToolOutput::Blocks { .. }));
869        let _ = std::fs::remove_file(p);
870
871        let mut j = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08];
872        j.extend_from_slice(&480u16.to_be_bytes());
873        j.extend_from_slice(&640u16.to_be_bytes());
874        j.extend_from_slice(&[3, 1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1, 0xFF, 0xD9]);
875        let p = tmp("ok.jpg", &j);
876        let out = ReadTool
877            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
878            .await
879            .unwrap();
880        assert!(
881            out.summary().contains("(640x480, image/jpeg, 1 KB)"),
882            "{}",
883            out.summary()
884        );
885        let _ = std::fs::remove_file(p);
886    }
887
888    // ── S4: metadata guard before the read ──────────────────────────────────
889
890    #[tokio::test]
891    async fn oversize_file_rejected_by_metadata_without_reading() {
892        // Sparse file: set_len allocates no blocks, so this is instant on
893        // disk but would be a 65 MiB read if the guard ran after the read.
894        let p = std::env::temp_dir().join(format!("read_sparse_{}.bin", std::process::id()));
895        let f = std::fs::File::create(&p).unwrap();
896        f.set_len(MAX_READ_BYTES + 1).unwrap();
897        drop(f);
898        let start = std::time::Instant::now();
899        let err = ReadTool
900            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
901            .await
902            .unwrap_err()
903            .to_string();
904        assert!(err.contains("refuses files over"), "{err}");
905        assert!(err.contains("head"), "{err}");
906        assert!(start.elapsed() < std::time::Duration::from_secs(2));
907        let _ = std::fs::remove_file(p);
908    }
909
910    #[tokio::test]
911    async fn directory_and_device_rejected_as_not_regular_file() {
912        let err = ReadTool
913            .execute_rich(
914                json!({"path": std::env::temp_dir().to_string_lossy()}),
915                create_tool_context(),
916            )
917            .await
918            .unwrap_err()
919            .to_string();
920        assert!(err.contains("not a regular file"), "{err}");
921
922        #[cfg(unix)]
923        if std::path::Path::new("/dev/zero").exists() {
924            let start = std::time::Instant::now();
925            let err = ReadTool
926                .execute_rich(json!({"path": "/dev/zero"}), create_tool_context())
927                .await
928                .unwrap_err()
929                .to_string();
930            assert!(err.contains("not a regular file"), "{err}");
931            assert!(start.elapsed() < std::time::Duration::from_secs(2));
932        }
933    }
934
935    #[tokio::test]
936    async fn non_image_binary_mentions_image_support() {
937        let p = tmp("blob.bin", &[0xFF, 0xFE, 0x00, 0x80, 0x81]);
938        let err = ReadTool
939            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
940            .await
941            .unwrap_err();
942        assert!(
943            err.to_string().contains("returned as images automatically"),
944            "{err}"
945        );
946        let _ = std::fs::remove_file(p);
947    }
948}