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