Skip to main content

locode_packs/grok/
mod.rs

1//! The `grok` pack — a faithful port of Grok Build's `xai-grok-tools` toolset, trimmed
2//! to headless-minimal (ADR-0012), over `locode-host`. Task 13 renders its real system
3//! prompt; all five grok tools are wired here.
4
5// Verbatim grok port — keeps gb's code shape; lint exceptions scoped to it.
6#[allow(clippy::pedantic, clippy::unwrap_used)]
7mod confusables;
8mod grep;
9mod list_dir;
10pub mod prompt;
11mod read;
12mod search_replace;
13mod terminal;
14
15use std::sync::Arc;
16
17use locode_host::Host;
18use locode_protocol::{ContentBlock, Message, Role};
19use locode_tools::Registry;
20
21use crate::pack::{Pack, PackContext};
22use grep::GrokGrep;
23use list_dir::GrokListDir;
24use read::GrokReadFile;
25use search_replace::GrokSearchReplace;
26use terminal::GrokRunTerminalCmd;
27
28/// The grok harness pack (a zero-sized `&'static` singleton).
29#[derive(Debug, Default, Clone, Copy)]
30pub struct GrokPack;
31
32impl Pack for GrokPack {
33    fn name(&self) -> &'static str {
34        "grok"
35    }
36
37    fn register(&self, host: &Arc<Host>, registry: &mut Registry) {
38        registry.register(
39            "run_terminal_cmd",
40            GrokRunTerminalCmd::new(Arc::clone(host)),
41        );
42        registry.register("read_file", GrokReadFile::new(Arc::clone(host)));
43        registry.register("search_replace", GrokSearchReplace::new(Arc::clone(host)));
44        registry.register("grep", GrokGrep::new(Arc::clone(host)));
45        registry.register("list_dir", GrokListDir::new(Arc::clone(host)));
46    }
47
48    fn preamble(&self, ctx: &PackContext) -> Vec<Message> {
49        // grok's real split (Task 13, ADR-0013): the rendered base prompt is the
50        // System item; environment info (cwd/OS/shell/date) is NOT in grok's
51        // system prompt — it rides in the first user message as the minimal
52        // `<user_info>` prefix (grok's own headless variant). The actual user
53        // prompt is wrapped in `<user_query>` by the exec layer (Task 14) via
54        // `prompt::user_query`.
55        vec![
56            Message {
57                role: Role::System,
58                content: vec![ContentBlock::Text {
59                    text: prompt::render_base_prompt(ctx),
60                }],
61            },
62            Message {
63                role: Role::User,
64                content: vec![ContentBlock::Text {
65                    text: prompt::user_info_block(ctx),
66                }],
67            },
68        ]
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use locode_host::HostConfig;
76    use locode_protocol::ResultChunk;
77    use locode_tools::ToolCtx;
78    use serde_json::json;
79    use std::path::Path;
80    use tokio_util::sync::CancellationToken;
81
82    /// A grok registry over a fresh temp workspace; the shell runs `bash -c` (non-login)
83    /// so login-profile output can't pollute the captured streams. Returns the host's
84    /// **canonical** root — the caller must set `ToolCtx.cwd` to match the jail root
85    /// (they agree by construction in the engine).
86    fn setup() -> (tempfile::TempDir, Registry, std::path::PathBuf) {
87        let dir = tempfile::tempdir().unwrap();
88        let mut config = HostConfig::new(dir.path());
89        config.login_shell = false;
90        let host = Arc::new(Host::new(config).unwrap());
91        let root = host.workspace_root().to_path_buf();
92        let registry = GrokPack.build_registry(&host);
93        (dir, registry, root)
94    }
95
96    fn ctx(dir: &Path) -> ToolCtx {
97        ToolCtx::new(
98            dir.to_path_buf(),
99            "c1".into(),
100            dir.to_path_buf(),
101            CancellationToken::new(),
102        )
103    }
104
105    fn result_text(block: &ContentBlock) -> String {
106        match block {
107            ContentBlock::ToolResult { content, .. } => content
108                .iter()
109                .filter_map(|chunk| match chunk {
110                    ResultChunk::Text { text } => Some(text.clone()),
111                    ResultChunk::Image { .. } => None,
112                })
113                .collect(),
114            _ => panic!("expected a tool_result"),
115        }
116    }
117
118    fn is_error(block: &ContentBlock) -> bool {
119        matches!(block, ContentBlock::ToolResult { is_error: true, .. })
120    }
121
122    #[tokio::test]
123    async fn run_terminal_cmd_echo() {
124        let (_dir, registry, root) = setup();
125        let out = registry
126            .dispatch(
127                "run_terminal_cmd",
128                json!({ "command": "echo hi", "description": "say hi" }),
129                &ctx(&root),
130            )
131            .await;
132        assert!(out.record.ok);
133        assert_eq!(out.record.output["exit_code"], json!(0));
134        let text = result_text(&out.tool_result);
135        assert!(text.contains("exit: 0"), "{text}");
136        assert!(text.contains("hi"), "{text}");
137    }
138
139    #[tokio::test]
140    async fn run_terminal_cmd_nonzero_exit_is_soft() {
141        let (_dir, registry, root) = setup();
142        let out = registry
143            .dispatch(
144                "run_terminal_cmd",
145                json!({ "command": "exit 3", "description": "fail" }),
146                &ctx(&root),
147            )
148            .await;
149        // Spawn succeeded → ok; the non-zero exit is data, not an error (ADR-0004).
150        assert!(out.record.ok);
151        assert!(!is_error(&out.tool_result));
152        assert_eq!(out.record.output["exit_code"], json!(3));
153    }
154
155    #[tokio::test]
156    async fn run_terminal_cmd_truncates_front_back_with_grok_markers() {
157        let (_dir, registry, root) = setup();
158        let out = registry
159            .dispatch(
160                "run_terminal_cmd",
161                json!({
162                    "command": "for i in $(seq 1 3000); do echo line-$i; done",
163                    "description": "spam output"
164                }),
165                &ctx(&root),
166            )
167            .await;
168        assert!(out.record.ok);
169        let text = result_text(&out.tool_result);
170        assert!(
171            text.starts_with("exit: 0 [truncated: showing first/last "),
172            "{}",
173            &text[..80]
174        );
175        assert!(text.contains(" - full output at: "), "spill path in header");
176        assert!(
177            text.contains("\n\n... (output truncated) ...\n\n"),
178            "grok separator"
179        );
180        assert!(text.contains("line-1\n"), "front retained");
181        assert!(text.contains("line-3000"), "back retained");
182        assert_eq!(out.record.output["truncated"], json!(true));
183    }
184
185    #[tokio::test]
186    async fn run_terminal_cmd_rejects_background_operator() {
187        let (_dir, registry, root) = setup();
188        let out = registry
189            .dispatch(
190                "run_terminal_cmd",
191                json!({ "command": "sleep 5 &", "description": "bg" }),
192                &ctx(&root),
193            )
194            .await;
195        assert!(is_error(&out.tool_result));
196        assert_eq!(
197            result_text(&out.tool_result),
198            "Remove the background '&' from your command; background execution is disabled."
199        );
200    }
201
202    #[tokio::test]
203    async fn read_file_numbers_lines() {
204        let (_dir, registry, root) = setup();
205        std::fs::write(root.join("f.txt"), "alpha\nbeta\ngamma\n").unwrap();
206        let out = registry
207            .dispatch("read_file", json!({ "target_file": "f.txt" }), &ctx(&root))
208            .await;
209        assert!(out.record.ok);
210        // grok's `matches('\n') + 1` semantics: the trailing newline yields a
211        // phantom 4th line (gb:442).
212        assert_eq!(out.record.output["lines"], json!(4));
213        assert_eq!(out.record.output["truncated"], json!(false));
214        // Sparse numbering (gb:249-256): only the first visible line is
215        // anchored here; lines 2-3 are bare; the phantom renders as `\n`.
216        let text = result_text(&out.tool_result);
217        assert_eq!(text, "1→alpha\nbeta\ngamma\n");
218    }
219
220    #[tokio::test]
221    async fn read_file_line_cap_truncates() {
222        use std::fmt::Write as _;
223        let (_dir, registry, root) = setup();
224        let mut big = String::new();
225        for n in 1..=1500 {
226            writeln!(big, "line {n}").unwrap();
227        }
228        std::fs::write(root.join("big.txt"), big).unwrap();
229        let out = registry
230            .dispatch(
231                "read_file",
232                json!({ "target_file": "big.txt" }),
233                &ctx(&root),
234            )
235            .await;
236        assert!(out.record.ok);
237        // 1500 lines + trailing newline = 1501 by grok's phantom counting.
238        assert_eq!(out.record.output["lines"], json!(1501));
239        assert_eq!(out.record.output["truncated"], json!(true));
240        // Body holds the first 1000 lines, sparse-numbered: decade anchors
241        // only (gb:249-256).
242        let text = result_text(&out.tool_result);
243        assert!(
244            text.starts_with("1→line 1\nline 2\n"),
245            "first anchor + bare"
246        );
247        assert!(text.contains("1000→line 1000"), "capped at 1000");
248        assert!(
249            !text.contains("990→line 990\nline 991\n991→"),
250            "no double anchors"
251        );
252        assert!(!text.contains("line 1001"), "line 1001 excluded");
253    }
254
255    #[tokio::test]
256    async fn read_file_not_found_is_soft_error() {
257        let (_dir, registry, root) = setup();
258        let out = registry
259            .dispatch(
260                "read_file",
261                json!({ "target_file": "nope.txt" }),
262                &ctx(&root),
263            )
264            .await;
265        assert!(!out.record.ok);
266        assert!(is_error(&out.tool_result));
267    }
268
269    #[tokio::test]
270    async fn read_file_outside_jail_is_soft_error() {
271        let (_dir, registry, root) = setup();
272        let out = registry
273            .dispatch(
274                "read_file",
275                json!({ "target_file": "/etc/passwd" }),
276                &ctx(&root),
277            )
278            .await;
279        assert!(!out.record.ok);
280        assert!(is_error(&out.tool_result));
281    }
282
283    // ---- search_replace ----
284
285    async fn edit(
286        registry: &Registry,
287        root: &Path,
288        args: serde_json::Value,
289    ) -> locode_tools::Dispatched {
290        registry.dispatch("search_replace", args, &ctx(root)).await
291    }
292
293    #[tokio::test]
294    async fn search_replace_creates_file_on_empty_old_string() {
295        let (_dir, registry, root) = setup();
296        let out = edit(
297            &registry,
298            &root,
299            json!({ "file_path": "new.txt", "old_string": "", "new_string": "hello world" }),
300        )
301        .await;
302        assert!(out.record.ok);
303        assert_eq!(out.record.output["created"], json!(true));
304        assert_eq!(
305            std::fs::read_to_string(root.join("new.txt")).unwrap(),
306            "hello world"
307        );
308    }
309
310    #[tokio::test]
311    async fn search_replace_edits_unique_match() {
312        let (_dir, registry, root) = setup();
313        std::fs::write(root.join("f.txt"), "alpha beta gamma").unwrap();
314        let out = edit(
315            &registry,
316            &root,
317            json!({ "file_path": "f.txt", "old_string": "beta", "new_string": "BETA" }),
318        )
319        .await;
320        assert!(out.record.ok);
321        assert_eq!(out.record.output["replacements"], json!(1));
322        assert_eq!(
323            std::fs::read_to_string(root.join("f.txt")).unwrap(),
324            "alpha BETA gamma"
325        );
326    }
327
328    #[tokio::test]
329    async fn search_replace_no_op_is_soft_error() {
330        let (_dir, registry, root) = setup();
331        std::fs::write(root.join("f.txt"), "x").unwrap();
332        let out = edit(
333            &registry,
334            &root,
335            json!({ "file_path": "f.txt", "old_string": "x", "new_string": "x" }),
336        )
337        .await;
338        assert!(!out.record.ok);
339        assert!(result_text(&out.tool_result).contains("same"));
340    }
341
342    #[tokio::test]
343    async fn search_replace_not_found_is_soft_error() {
344        let (_dir, registry, root) = setup();
345        std::fs::write(root.join("f.txt"), "abc").unwrap();
346        let out = edit(
347            &registry,
348            &root,
349            json!({ "file_path": "f.txt", "old_string": "zzz", "new_string": "q" }),
350        )
351        .await;
352        assert!(!out.record.ok);
353        assert!(result_text(&out.tool_result).contains("not found"));
354    }
355
356    #[tokio::test]
357    async fn search_replace_multiple_matches_without_replace_all_is_soft_error() {
358        let (_dir, registry, root) = setup();
359        std::fs::write(root.join("f.txt"), "a a a").unwrap();
360        let out = edit(
361            &registry,
362            &root,
363            json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b" }),
364        )
365        .await;
366        assert!(!out.record.ok);
367        assert!(result_text(&out.tool_result).contains("multiple times"));
368    }
369
370    #[tokio::test]
371    async fn search_replace_replace_all() {
372        let (_dir, registry, root) = setup();
373        std::fs::write(root.join("f.txt"), "a a a").unwrap();
374        let out = edit(
375            &registry,
376            &root,
377            json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b", "replace_all": true }),
378        )
379        .await;
380        assert!(out.record.ok);
381        assert_eq!(out.record.output["replacements"], json!(3));
382        assert_eq!(
383            std::fs::read_to_string(root.join("f.txt")).unwrap(),
384            "b b b"
385        );
386    }
387
388    #[tokio::test]
389    async fn search_replace_empty_old_string_overwrites_existing() {
390        // grok's default: empty old_string OVERWRITES a non-empty file
391        // (gb:283-293 + test gb:1264-1282); the Task 11 error was invented.
392        let (_dir, registry, root) = setup();
393        std::fs::write(root.join("f.txt"), "previous content").unwrap();
394        let out = edit(
395            &registry,
396            &root,
397            json!({ "file_path": "f.txt", "old_string": "", "new_string": "fresh" }),
398        )
399        .await;
400        assert!(out.record.ok);
401        assert_eq!(
402            result_text(&out.tool_result),
403            "The file f.txt has been created successfully."
404        );
405        assert_eq!(
406            std::fs::read_to_string(root.join("f.txt")).unwrap(),
407            "fresh"
408        );
409    }
410
411    #[tokio::test]
412    async fn search_replace_success_texts_match_grok_current() {
413        let (_dir, registry, root) = setup();
414        std::fs::write(root.join("f.txt"), "one two one").unwrap();
415        let out = edit(
416            &registry,
417            &root,
418            json!({ "file_path": "f.txt", "old_string": "two", "new_string": "TWO" }),
419        )
420        .await;
421        assert_eq!(
422            result_text(&out.tool_result),
423            "The file f.txt has been updated successfully."
424        );
425        let out = edit(
426            &registry,
427            &root,
428            json!({ "file_path": "f.txt", "old_string": "one", "new_string": "ONE", "replace_all": true }),
429        )
430        .await;
431        assert_eq!(
432            result_text(&out.tool_result),
433            "The file f.txt has been updated. All occurrences were successfully replaced."
434        );
435    }
436
437    #[tokio::test]
438    async fn search_replace_no_match_carries_current_era_hints() {
439        let (_dir, registry, root) = setup();
440        std::fs::write(root.join("f.txt"), "alpha\nthe quick fox\n").unwrap();
441        let out = edit(
442            &registry,
443            &root,
444            json!({ "file_path": "f.txt", "old_string": "the quick cat", "new_string": "x" }),
445        )
446        .await;
447        assert!(!out.record.ok);
448        let text = result_text(&out.tool_result);
449        assert!(
450            text.starts_with(
451                "The string to replace was not found in the file, use the read_file tool to see the correct string. The user may have changed the file since you last read it."
452            ),
453            "{text}"
454        );
455        assert!(
456            text.contains("\n\nNearest match: line 2: the quick fox"),
457            "{text}"
458        );
459    }
460
461    #[tokio::test]
462    async fn search_replace_crlf_roundtrip() {
463        // gb:553-563, 688-692: LF-normalized matching, CRLF re-expanded write.
464        let (_dir, registry, root) = setup();
465        std::fs::write(root.join("f.txt"), "line one\r\nline two\r\n").unwrap();
466        let out = edit(
467            &registry,
468            &root,
469            json!({ "file_path": "f.txt", "old_string": "one\nline two", "new_string": "1\nline 2" }),
470        )
471        .await;
472        assert!(out.record.ok, "{:?}", result_text(&out.tool_result));
473        assert_eq!(
474            std::fs::read_to_string(root.join("f.txt")).unwrap(),
475            "line 1\r\nline 2\r\n"
476        );
477    }
478
479    #[tokio::test]
480    async fn search_replace_gitignore_guard_blocks_edit() {
481        let (_dir, registry, root) = setup();
482        std::fs::create_dir(root.join(".git")).unwrap();
483        std::fs::write(root.join(".gitignore"), "*.log\n").unwrap();
484        std::fs::write(root.join("app.log"), "data").unwrap();
485        let out = edit(
486            &registry,
487            &root,
488            json!({ "file_path": "app.log", "old_string": "data", "new_string": "x" }),
489        )
490        .await;
491        assert!(!out.record.ok);
492        assert_eq!(
493            result_text(&out.tool_result),
494            "Error: app.log is ignored by .gitignore and cannot be edited."
495        );
496    }
497
498    #[tokio::test]
499    async fn search_replace_not_found_uses_current_text() {
500        let (_dir, registry, root) = setup();
501        let out = edit(
502            &registry,
503            &root,
504            json!({ "file_path": "missing.txt", "old_string": "a", "new_string": "b" }),
505        )
506        .await;
507        assert!(!out.record.ok);
508        assert_eq!(
509            result_text(&out.tool_result),
510            "Error: missing.txt does not exist."
511        );
512    }
513
514    // ---- grep (needs `rg`; the happy path is gated on its presence) ----
515
516    fn rg_present() -> bool {
517        std::process::Command::new("rg")
518            .arg("--version")
519            .output()
520            .is_ok()
521    }
522
523    #[tokio::test]
524    async fn grep_finds_matches() {
525        if !rg_present() {
526            return;
527        }
528        let (_dir, registry, root) = setup();
529        std::fs::write(root.join("a.txt"), "needle here\nother line\n").unwrap();
530        std::fs::write(root.join("b.txt"), "nothing\n").unwrap();
531        let out = registry
532            .dispatch("grep", json!({ "pattern": "needle" }), &ctx(&root))
533            .await;
534        assert!(out.record.ok);
535        assert_eq!(out.record.output["matched"], json!(true));
536        let text = result_text(&out.tool_result);
537        assert!(text.contains("needle"), "{text}");
538        assert!(text.contains("a.txt"), "{text}");
539    }
540
541    #[tokio::test]
542    async fn grep_no_match_is_soft_ok() {
543        if !rg_present() {
544            return;
545        }
546        let (_dir, registry, root) = setup();
547        std::fs::write(root.join("a.txt"), "hello\n").unwrap();
548        let out = registry
549            .dispatch("grep", json!({ "pattern": "zzzznotfound" }), &ctx(&root))
550            .await;
551        assert!(out.record.ok);
552        assert_eq!(out.record.output["matched"], json!(false));
553        assert!(result_text(&out.tool_result).contains("No matches"));
554    }
555
556    // ---- list_dir (self-implemented walk; no external binary) ----
557
558    #[tokio::test]
559    async fn list_dir_walks_the_tree() {
560        let (_dir, registry, root) = setup();
561        std::fs::create_dir(root.join("src")).unwrap();
562        std::fs::write(root.join("src/main.rs"), "").unwrap();
563        std::fs::write(root.join("Cargo.toml"), "").unwrap();
564        let out = registry
565            .dispatch("list_dir", json!({ "target_directory": "." }), &ctx(&root))
566            .await;
567        assert!(out.record.ok);
568        // grok's Current format: `- {display}/` header + `- ` bullets, merged
569        // case-insensitive sort, dirs suffixed `/` (gb:198, 243-246, 569).
570        let text = result_text(&out.tool_result);
571        assert_eq!(
572            text,
573            format!(
574                "- {}/\n  - Cargo.toml\n  - src/\n    - main.rs",
575                root.display()
576            ),
577            "{text}"
578        );
579    }
580
581    #[tokio::test]
582    async fn list_dir_hides_dotfiles_and_respects_gitignore() {
583        let (_dir, registry, root) = setup();
584        std::fs::create_dir(root.join(".git")).unwrap();
585        std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
586        std::fs::write(root.join(".hidden"), "").unwrap();
587        std::fs::create_dir(root.join("target")).unwrap();
588        std::fs::write(root.join("target/out.o"), "").unwrap();
589        std::fs::write(root.join("visible.rs"), "").unwrap();
590        let out = registry
591            .dispatch("list_dir", json!({ "target_directory": "." }), &ctx(&root))
592            .await;
593        let text = result_text(&out.tool_result);
594        assert!(text.contains("- visible.rs"), "{text}");
595        assert!(!text.contains(".hidden"), "dot-files hidden: {text}");
596        assert!(!text.contains(".gitignore"), "dot-files hidden: {text}");
597        assert!(!text.contains("target"), "gitignored excluded: {text}");
598    }
599
600    #[tokio::test]
601    async fn list_dir_missing_is_soft_error_with_grok_text() {
602        let (_dir, registry, root) = setup();
603        let out = registry
604            .dispatch(
605                "list_dir",
606                json!({ "target_directory": "nope" }),
607                &ctx(&root),
608            )
609            .await;
610        assert!(!out.record.ok);
611        assert!(is_error(&out.tool_result));
612        assert_eq!(
613            result_text(&out.tool_result),
614            format!("Error: {}/nope does not exist.", root.display())
615        );
616    }
617
618    #[tokio::test]
619    async fn list_dir_file_target_is_soft_error_with_grok_text() {
620        let (_dir, registry, root) = setup();
621        std::fs::write(root.join("f.txt"), "x").unwrap();
622        let out = registry
623            .dispatch(
624                "list_dir",
625                json!({ "target_directory": "f.txt" }),
626                &ctx(&root),
627            )
628            .await;
629        assert!(!out.record.ok);
630        assert_eq!(
631            result_text(&out.tool_result),
632            format!(
633                "Error: {}/f.txt is a file, not a directory.",
634                root.display()
635            )
636        );
637    }
638
639    #[tokio::test]
640    async fn list_dir_summarizes_fat_subdir() {
641        let (_dir, registry, root) = setup();
642        std::fs::create_dir(root.join("fat")).unwrap();
643        // ~230 files * ~14 chars/line ≈ 3.2k chars... use enough to overflow
644        // the 10k budget so `fat/` collapses to a summary.
645        for i in 0..900 {
646            std::fs::write(root.join("fat").join(format!("file-{i:04}.rs")), "").unwrap();
647        }
648        std::fs::write(root.join("small.txt"), "").unwrap();
649        let out = registry
650            .dispatch("list_dir", json!({ "target_directory": "." }), &ctx(&root))
651            .await;
652        let text = result_text(&out.tool_result);
653        assert!(text.contains("- fat/"), "{text}");
654        assert!(
655            text.contains("[900 files in subtree: 900 *.rs]"),
656            "collapsed summary: {text}"
657        );
658        assert!(
659            !text.contains("file-0000.rs"),
660            "children not listed: {text}"
661        );
662        assert!(text.contains("- small.txt"), "{text}");
663    }
664}