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    // Dispatches through the host's `-c`-style POSIX shell.
123    #[cfg(unix)]
124    #[tokio::test]
125    async fn run_terminal_cmd_echo() {
126        let (_dir, registry, root) = setup();
127        let out = registry
128            .dispatch(
129                "run_terminal_cmd",
130                json!({ "command": "echo hi", "description": "say hi" }),
131                &ctx(&root),
132            )
133            .await;
134        assert!(out.record.ok);
135        assert_eq!(out.record.output["exit_code"], json!(0));
136        let text = result_text(&out.tool_result);
137        assert!(text.contains("exit: 0"), "{text}");
138        assert!(text.contains("hi"), "{text}");
139    }
140
141    // Dispatches through the host's `-c`-style POSIX shell.
142    #[cfg(unix)]
143    #[tokio::test]
144    async fn run_terminal_cmd_nonzero_exit_is_soft() {
145        let (_dir, registry, root) = setup();
146        let out = registry
147            .dispatch(
148                "run_terminal_cmd",
149                json!({ "command": "exit 3", "description": "fail" }),
150                &ctx(&root),
151            )
152            .await;
153        // Spawn succeeded → ok; the non-zero exit is data, not an error (ADR-0004).
154        assert!(out.record.ok);
155        assert!(!is_error(&out.tool_result));
156        assert_eq!(out.record.output["exit_code"], json!(3));
157    }
158
159    // Dispatches through the POSIX shell; also needs `seq`.
160    #[cfg(unix)]
161    #[tokio::test]
162    async fn run_terminal_cmd_truncates_front_back_with_grok_markers() {
163        let (_dir, registry, root) = setup();
164        let out = registry
165            .dispatch(
166                "run_terminal_cmd",
167                json!({
168                    "command": "for i in $(seq 1 3000); do echo line-$i; done",
169                    "description": "spam output"
170                }),
171                &ctx(&root),
172            )
173            .await;
174        assert!(out.record.ok);
175        let text = result_text(&out.tool_result);
176        assert!(
177            text.starts_with("exit: 0 [truncated: showing first/last "),
178            "{}",
179            &text[..80]
180        );
181        assert!(text.contains(" - full output at: "), "spill path in header");
182        assert!(
183            text.contains("\n\n... (output truncated) ...\n\n"),
184            "grok separator"
185        );
186        assert!(text.contains("line-1\n"), "front retained");
187        assert!(text.contains("line-3000"), "back retained");
188        assert_eq!(out.record.output["truncated"], json!(true));
189    }
190
191    #[tokio::test]
192    async fn run_terminal_cmd_rejects_background_operator() {
193        let (_dir, registry, root) = setup();
194        let out = registry
195            .dispatch(
196                "run_terminal_cmd",
197                json!({ "command": "sleep 5 &", "description": "bg" }),
198                &ctx(&root),
199            )
200            .await;
201        assert!(is_error(&out.tool_result));
202        assert_eq!(
203            result_text(&out.tool_result),
204            "Remove the background '&' from your command; background execution is disabled."
205        );
206    }
207
208    #[tokio::test]
209    async fn read_file_numbers_lines() {
210        let (_dir, registry, root) = setup();
211        std::fs::write(root.join("f.txt"), "alpha\nbeta\ngamma\n").unwrap();
212        let out = registry
213            .dispatch("read_file", json!({ "target_file": "f.txt" }), &ctx(&root))
214            .await;
215        assert!(out.record.ok);
216        // grok's `matches('\n') + 1` semantics: the trailing newline yields a
217        // phantom 4th line (gb:442).
218        assert_eq!(out.record.output["lines"], json!(4));
219        assert_eq!(out.record.output["truncated"], json!(false));
220        // Sparse numbering (gb:249-256): only the first visible line is
221        // anchored here; lines 2-3 are bare; the phantom renders as `\n`.
222        let text = result_text(&out.tool_result);
223        assert_eq!(text, "1→alpha\nbeta\ngamma\n");
224    }
225
226    #[tokio::test]
227    async fn read_file_line_cap_truncates() {
228        use std::fmt::Write as _;
229        let (_dir, registry, root) = setup();
230        let mut big = String::new();
231        for n in 1..=1500 {
232            writeln!(big, "line {n}").unwrap();
233        }
234        std::fs::write(root.join("big.txt"), big).unwrap();
235        let out = registry
236            .dispatch(
237                "read_file",
238                json!({ "target_file": "big.txt" }),
239                &ctx(&root),
240            )
241            .await;
242        assert!(out.record.ok);
243        // 1500 lines + trailing newline = 1501 by grok's phantom counting.
244        assert_eq!(out.record.output["lines"], json!(1501));
245        assert_eq!(out.record.output["truncated"], json!(true));
246        // Body holds the first 1000 lines, sparse-numbered: decade anchors
247        // only (gb:249-256).
248        let text = result_text(&out.tool_result);
249        assert!(
250            text.starts_with("1→line 1\nline 2\n"),
251            "first anchor + bare"
252        );
253        assert!(text.contains("1000→line 1000"), "capped at 1000");
254        assert!(
255            !text.contains("990→line 990\nline 991\n991→"),
256            "no double anchors"
257        );
258        assert!(!text.contains("line 1001"), "line 1001 excluded");
259    }
260
261    #[tokio::test]
262    async fn read_file_not_found_is_soft_error() {
263        let (_dir, registry, root) = setup();
264        let out = registry
265            .dispatch(
266                "read_file",
267                json!({ "target_file": "nope.txt" }),
268                &ctx(&root),
269            )
270            .await;
271        assert!(!out.record.ok);
272        assert!(is_error(&out.tool_result));
273    }
274
275    #[tokio::test]
276    async fn read_file_outside_jail_is_soft_error() {
277        let (_dir, registry, root) = setup();
278        let out = registry
279            .dispatch(
280                "read_file",
281                json!({ "target_file": "/etc/passwd" }),
282                &ctx(&root),
283            )
284            .await;
285        assert!(!out.record.ok);
286        assert!(is_error(&out.tool_result));
287    }
288
289    // ---- search_replace ----
290
291    async fn edit(
292        registry: &Registry,
293        root: &Path,
294        args: serde_json::Value,
295    ) -> locode_tools::Dispatched {
296        registry.dispatch("search_replace", args, &ctx(root)).await
297    }
298
299    #[tokio::test]
300    async fn search_replace_creates_file_on_empty_old_string() {
301        let (_dir, registry, root) = setup();
302        let out = edit(
303            &registry,
304            &root,
305            json!({ "file_path": "new.txt", "old_string": "", "new_string": "hello world" }),
306        )
307        .await;
308        assert!(out.record.ok);
309        assert_eq!(out.record.output["created"], json!(true));
310        assert_eq!(
311            std::fs::read_to_string(root.join("new.txt")).unwrap(),
312            "hello world"
313        );
314    }
315
316    #[tokio::test]
317    async fn search_replace_edits_unique_match() {
318        let (_dir, registry, root) = setup();
319        std::fs::write(root.join("f.txt"), "alpha beta gamma").unwrap();
320        let out = edit(
321            &registry,
322            &root,
323            json!({ "file_path": "f.txt", "old_string": "beta", "new_string": "BETA" }),
324        )
325        .await;
326        assert!(out.record.ok);
327        assert_eq!(out.record.output["replacements"], json!(1));
328        assert_eq!(
329            std::fs::read_to_string(root.join("f.txt")).unwrap(),
330            "alpha BETA gamma"
331        );
332    }
333
334    #[tokio::test]
335    async fn search_replace_no_op_is_soft_error() {
336        let (_dir, registry, root) = setup();
337        std::fs::write(root.join("f.txt"), "x").unwrap();
338        let out = edit(
339            &registry,
340            &root,
341            json!({ "file_path": "f.txt", "old_string": "x", "new_string": "x" }),
342        )
343        .await;
344        assert!(!out.record.ok);
345        assert!(result_text(&out.tool_result).contains("same"));
346    }
347
348    #[tokio::test]
349    async fn search_replace_not_found_is_soft_error() {
350        let (_dir, registry, root) = setup();
351        std::fs::write(root.join("f.txt"), "abc").unwrap();
352        let out = edit(
353            &registry,
354            &root,
355            json!({ "file_path": "f.txt", "old_string": "zzz", "new_string": "q" }),
356        )
357        .await;
358        assert!(!out.record.ok);
359        assert!(result_text(&out.tool_result).contains("not found"));
360    }
361
362    #[tokio::test]
363    async fn search_replace_multiple_matches_without_replace_all_is_soft_error() {
364        let (_dir, registry, root) = setup();
365        std::fs::write(root.join("f.txt"), "a a a").unwrap();
366        let out = edit(
367            &registry,
368            &root,
369            json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b" }),
370        )
371        .await;
372        assert!(!out.record.ok);
373        assert!(result_text(&out.tool_result).contains("multiple times"));
374    }
375
376    #[tokio::test]
377    async fn search_replace_replace_all() {
378        let (_dir, registry, root) = setup();
379        std::fs::write(root.join("f.txt"), "a a a").unwrap();
380        let out = edit(
381            &registry,
382            &root,
383            json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b", "replace_all": true }),
384        )
385        .await;
386        assert!(out.record.ok);
387        assert_eq!(out.record.output["replacements"], json!(3));
388        assert_eq!(
389            std::fs::read_to_string(root.join("f.txt")).unwrap(),
390            "b b b"
391        );
392    }
393
394    #[tokio::test]
395    async fn search_replace_empty_old_string_overwrites_existing() {
396        // grok's default: empty old_string OVERWRITES a non-empty file
397        // (gb:283-293 + test gb:1264-1282); the Task 11 error was invented.
398        let (_dir, registry, root) = setup();
399        std::fs::write(root.join("f.txt"), "previous content").unwrap();
400        let out = edit(
401            &registry,
402            &root,
403            json!({ "file_path": "f.txt", "old_string": "", "new_string": "fresh" }),
404        )
405        .await;
406        assert!(out.record.ok);
407        assert_eq!(
408            result_text(&out.tool_result),
409            "The file f.txt has been created successfully."
410        );
411        assert_eq!(
412            std::fs::read_to_string(root.join("f.txt")).unwrap(),
413            "fresh"
414        );
415    }
416
417    #[tokio::test]
418    async fn search_replace_success_texts_match_grok_current() {
419        let (_dir, registry, root) = setup();
420        std::fs::write(root.join("f.txt"), "one two one").unwrap();
421        let out = edit(
422            &registry,
423            &root,
424            json!({ "file_path": "f.txt", "old_string": "two", "new_string": "TWO" }),
425        )
426        .await;
427        assert_eq!(
428            result_text(&out.tool_result),
429            "The file f.txt has been updated successfully."
430        );
431        let out = edit(
432            &registry,
433            &root,
434            json!({ "file_path": "f.txt", "old_string": "one", "new_string": "ONE", "replace_all": true }),
435        )
436        .await;
437        assert_eq!(
438            result_text(&out.tool_result),
439            "The file f.txt has been updated. All occurrences were successfully replaced."
440        );
441    }
442
443    #[tokio::test]
444    async fn search_replace_no_match_carries_current_era_hints() {
445        let (_dir, registry, root) = setup();
446        std::fs::write(root.join("f.txt"), "alpha\nthe quick fox\n").unwrap();
447        let out = edit(
448            &registry,
449            &root,
450            json!({ "file_path": "f.txt", "old_string": "the quick cat", "new_string": "x" }),
451        )
452        .await;
453        assert!(!out.record.ok);
454        let text = result_text(&out.tool_result);
455        assert!(
456            text.starts_with(
457                "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."
458            ),
459            "{text}"
460        );
461        assert!(
462            text.contains("\n\nNearest match: line 2: the quick fox"),
463            "{text}"
464        );
465    }
466
467    #[tokio::test]
468    async fn search_replace_crlf_roundtrip() {
469        // gb:553-563, 688-692: LF-normalized matching, CRLF re-expanded write.
470        let (_dir, registry, root) = setup();
471        std::fs::write(root.join("f.txt"), "line one\r\nline two\r\n").unwrap();
472        let out = edit(
473            &registry,
474            &root,
475            json!({ "file_path": "f.txt", "old_string": "one\nline two", "new_string": "1\nline 2" }),
476        )
477        .await;
478        assert!(out.record.ok, "{:?}", result_text(&out.tool_result));
479        assert_eq!(
480            std::fs::read_to_string(root.join("f.txt")).unwrap(),
481            "line 1\r\nline 2\r\n"
482        );
483    }
484
485    #[tokio::test]
486    async fn search_replace_gitignore_guard_blocks_edit() {
487        let (_dir, registry, root) = setup();
488        std::fs::create_dir(root.join(".git")).unwrap();
489        std::fs::write(root.join(".gitignore"), "*.log\n").unwrap();
490        std::fs::write(root.join("app.log"), "data").unwrap();
491        let out = edit(
492            &registry,
493            &root,
494            json!({ "file_path": "app.log", "old_string": "data", "new_string": "x" }),
495        )
496        .await;
497        assert!(!out.record.ok);
498        assert_eq!(
499            result_text(&out.tool_result),
500            "Error: app.log is ignored by .gitignore and cannot be edited."
501        );
502    }
503
504    #[tokio::test]
505    async fn search_replace_not_found_uses_current_text() {
506        let (_dir, registry, root) = setup();
507        let out = edit(
508            &registry,
509            &root,
510            json!({ "file_path": "missing.txt", "old_string": "a", "new_string": "b" }),
511        )
512        .await;
513        assert!(!out.record.ok);
514        assert_eq!(
515            result_text(&out.tool_result),
516            "Error: missing.txt does not exist."
517        );
518    }
519
520    // ---- grep (needs `rg`; the happy path is gated on its presence) ----
521
522    fn rg_present() -> bool {
523        std::process::Command::new("rg")
524            .arg("--version")
525            .output()
526            .is_ok()
527    }
528
529    #[tokio::test]
530    async fn grep_finds_matches() {
531        if !rg_present() {
532            return;
533        }
534        let (_dir, registry, root) = setup();
535        std::fs::write(root.join("a.txt"), "needle here\nother line\n").unwrap();
536        std::fs::write(root.join("b.txt"), "nothing\n").unwrap();
537        let out = registry
538            .dispatch("grep", json!({ "pattern": "needle" }), &ctx(&root))
539            .await;
540        assert!(out.record.ok);
541        assert_eq!(out.record.output["matched"], json!(true));
542        let text = result_text(&out.tool_result);
543        assert!(text.contains("needle"), "{text}");
544        assert!(text.contains("a.txt"), "{text}");
545    }
546
547    #[tokio::test]
548    async fn grep_no_match_is_soft_ok() {
549        if !rg_present() {
550            return;
551        }
552        let (_dir, registry, root) = setup();
553        std::fs::write(root.join("a.txt"), "hello\n").unwrap();
554        let out = registry
555            .dispatch("grep", json!({ "pattern": "zzzznotfound" }), &ctx(&root))
556            .await;
557        assert!(out.record.ok);
558        assert_eq!(out.record.output["matched"], json!(false));
559        assert!(result_text(&out.tool_result).contains("No matches"));
560    }
561
562    // ---- list_dir (self-implemented walk; no external binary) ----
563
564    #[tokio::test]
565    async fn list_dir_walks_the_tree() {
566        let (_dir, registry, root) = setup();
567        std::fs::create_dir(root.join("src")).unwrap();
568        std::fs::write(root.join("src/main.rs"), "").unwrap();
569        std::fs::write(root.join("Cargo.toml"), "").unwrap();
570        let out = registry
571            .dispatch("list_dir", json!({ "target_directory": "." }), &ctx(&root))
572            .await;
573        assert!(out.record.ok);
574        // grok's Current format: `- {display}/` header + `- ` bullets, merged
575        // case-insensitive sort, dirs suffixed `/` (gb:198, 243-246, 569).
576        let text = result_text(&out.tool_result);
577        assert_eq!(
578            text,
579            format!(
580                "- {}/\n  - Cargo.toml\n  - src/\n    - main.rs",
581                root.display()
582            ),
583            "{text}"
584        );
585    }
586
587    #[tokio::test]
588    async fn list_dir_hides_dotfiles_and_respects_gitignore() {
589        let (_dir, registry, root) = setup();
590        std::fs::create_dir(root.join(".git")).unwrap();
591        std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
592        std::fs::write(root.join(".hidden"), "").unwrap();
593        std::fs::create_dir(root.join("target")).unwrap();
594        std::fs::write(root.join("target/out.o"), "").unwrap();
595        std::fs::write(root.join("visible.rs"), "").unwrap();
596        let out = registry
597            .dispatch("list_dir", json!({ "target_directory": "." }), &ctx(&root))
598            .await;
599        let text = result_text(&out.tool_result);
600        assert!(text.contains("- visible.rs"), "{text}");
601        assert!(!text.contains(".hidden"), "dot-files hidden: {text}");
602        assert!(!text.contains(".gitignore"), "dot-files hidden: {text}");
603        assert!(!text.contains("target"), "gitignored excluded: {text}");
604    }
605
606    #[tokio::test]
607    async fn list_dir_missing_is_soft_error_with_grok_text() {
608        let (_dir, registry, root) = setup();
609        let out = registry
610            .dispatch(
611                "list_dir",
612                json!({ "target_directory": "nope" }),
613                &ctx(&root),
614            )
615            .await;
616        assert!(!out.record.ok);
617        assert!(is_error(&out.tool_result));
618        assert_eq!(
619            result_text(&out.tool_result),
620            format!("Error: {}/nope does not exist.", root.display())
621        );
622    }
623
624    #[tokio::test]
625    async fn list_dir_file_target_is_soft_error_with_grok_text() {
626        let (_dir, registry, root) = setup();
627        std::fs::write(root.join("f.txt"), "x").unwrap();
628        let out = registry
629            .dispatch(
630                "list_dir",
631                json!({ "target_directory": "f.txt" }),
632                &ctx(&root),
633            )
634            .await;
635        assert!(!out.record.ok);
636        assert_eq!(
637            result_text(&out.tool_result),
638            format!(
639                "Error: {}/f.txt is a file, not a directory.",
640                root.display()
641            )
642        );
643    }
644
645    #[tokio::test]
646    async fn list_dir_summarizes_fat_subdir() {
647        let (_dir, registry, root) = setup();
648        std::fs::create_dir(root.join("fat")).unwrap();
649        // ~230 files * ~14 chars/line ≈ 3.2k chars... use enough to overflow
650        // the 10k budget so `fat/` collapses to a summary.
651        for i in 0..900 {
652            std::fs::write(root.join("fat").join(format!("file-{i:04}.rs")), "").unwrap();
653        }
654        std::fs::write(root.join("small.txt"), "").unwrap();
655        let out = registry
656            .dispatch("list_dir", json!({ "target_directory": "." }), &ctx(&root))
657            .await;
658        let text = result_text(&out.tool_result);
659        assert!(text.contains("- fat/"), "{text}");
660        assert!(
661            text.contains("[900 files in subtree: 900 *.rs]"),
662            "collapsed summary: {text}"
663        );
664        assert!(
665            !text.contains("file-0000.rs"),
666            "children not listed: {text}"
667        );
668        assert!(text.contains("- small.txt"), "{text}");
669    }
670}