1mod grep;
6mod list_dir;
7pub mod prompt;
8mod read;
9mod search_replace;
10mod terminal;
11
12use std::sync::Arc;
13
14use locode_host::Host;
15use locode_protocol::{ContentBlock, Message, Role};
16use locode_tools::Registry;
17
18use crate::pack::{Pack, PackContext};
19use grep::GrokGrep;
20use list_dir::GrokListDir;
21use read::GrokReadFile;
22use search_replace::GrokSearchReplace;
23use terminal::GrokRunTerminalCmd;
24
25#[derive(Debug, Default, Clone, Copy)]
27pub struct GrokPack;
28
29impl Pack for GrokPack {
30 fn name(&self) -> &'static str {
31 "grok"
32 }
33
34 fn register(&self, host: &Arc<Host>, registry: &mut Registry) {
35 registry.register(
36 "run_terminal_cmd",
37 GrokRunTerminalCmd::new(Arc::clone(host)),
38 );
39 registry.register("read_file", GrokReadFile::new(Arc::clone(host)));
40 registry.register("search_replace", GrokSearchReplace::new(Arc::clone(host)));
41 registry.register("grep", GrokGrep::new(Arc::clone(host)));
42 registry.register("list_dir", GrokListDir::new(Arc::clone(host)));
43 }
44
45 fn preamble(&self, ctx: &PackContext) -> Vec<Message> {
46 vec![
53 Message {
54 role: Role::System,
55 content: vec![ContentBlock::Text {
56 text: prompt::render_base_prompt(ctx),
57 }],
58 },
59 Message {
60 role: Role::User,
61 content: vec![ContentBlock::Text {
62 text: prompt::user_info_block(ctx),
63 }],
64 },
65 ]
66 }
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72 use locode_host::HostConfig;
73 use locode_protocol::ResultChunk;
74 use locode_tools::ToolCtx;
75 use serde_json::json;
76 use std::path::Path;
77 use tokio_util::sync::CancellationToken;
78
79 fn setup() -> (tempfile::TempDir, Registry, std::path::PathBuf) {
84 let dir = tempfile::tempdir().unwrap();
85 let mut config = HostConfig::new(dir.path());
86 config.login_shell = false;
87 let host = Arc::new(Host::new(config).unwrap());
88 let root = host.workspace_root().to_path_buf();
89 let registry = GrokPack.build_registry(&host);
90 (dir, registry, root)
91 }
92
93 fn ctx(dir: &Path) -> ToolCtx {
94 ToolCtx::new(
95 dir.to_path_buf(),
96 "c1".into(),
97 dir.to_path_buf(),
98 CancellationToken::new(),
99 )
100 }
101
102 fn result_text(block: &ContentBlock) -> String {
103 match block {
104 ContentBlock::ToolResult { content, .. } => content
105 .iter()
106 .filter_map(|chunk| match chunk {
107 ResultChunk::Text { text } => Some(text.clone()),
108 ResultChunk::Image { .. } => None,
109 })
110 .collect(),
111 _ => panic!("expected a tool_result"),
112 }
113 }
114
115 fn is_error(block: &ContentBlock) -> bool {
116 matches!(block, ContentBlock::ToolResult { is_error: true, .. })
117 }
118
119 #[tokio::test]
120 async fn run_terminal_cmd_echo() {
121 let (_dir, registry, root) = setup();
122 let out = registry
123 .dispatch(
124 "run_terminal_cmd",
125 json!({ "command": "echo hi", "description": "say hi" }),
126 &ctx(&root),
127 )
128 .await;
129 assert!(out.record.ok);
130 assert_eq!(out.record.output["exit_code"], json!(0));
131 let text = result_text(&out.tool_result);
132 assert!(text.contains("exit: 0"), "{text}");
133 assert!(text.contains("hi"), "{text}");
134 }
135
136 #[tokio::test]
137 async fn run_terminal_cmd_nonzero_exit_is_soft() {
138 let (_dir, registry, root) = setup();
139 let out = registry
140 .dispatch(
141 "run_terminal_cmd",
142 json!({ "command": "exit 3", "description": "fail" }),
143 &ctx(&root),
144 )
145 .await;
146 assert!(out.record.ok);
148 assert!(!is_error(&out.tool_result));
149 assert_eq!(out.record.output["exit_code"], json!(3));
150 }
151
152 #[tokio::test]
153 async fn read_file_numbers_lines() {
154 let (_dir, registry, root) = setup();
155 std::fs::write(root.join("f.txt"), "alpha\nbeta\ngamma\n").unwrap();
156 let out = registry
157 .dispatch("read_file", json!({ "target_file": "f.txt" }), &ctx(&root))
158 .await;
159 assert!(out.record.ok);
160 assert_eq!(out.record.output["lines"], json!(3));
161 assert_eq!(out.record.output["truncated"], json!(false));
162 let text = result_text(&out.tool_result);
163 assert!(text.contains("1→alpha"), "{text}");
164 assert!(text.contains("3→gamma"), "{text}");
165 }
166
167 #[tokio::test]
168 async fn read_file_line_cap_truncates() {
169 use std::fmt::Write as _;
170 let (_dir, registry, root) = setup();
171 let mut big = String::new();
172 for n in 1..=1500 {
173 writeln!(big, "line {n}").unwrap();
174 }
175 std::fs::write(root.join("big.txt"), big).unwrap();
176 let out = registry
177 .dispatch(
178 "read_file",
179 json!({ "target_file": "big.txt" }),
180 &ctx(&root),
181 )
182 .await;
183 assert!(out.record.ok);
184 assert_eq!(out.record.output["lines"], json!(1500));
185 assert_eq!(out.record.output["truncated"], json!(true));
186 let text = result_text(&out.tool_result);
188 assert!(text.contains("1000→line 1000"), "capped at 1000");
189 assert!(!text.contains("1001→"), "line 1001 excluded");
190 }
191
192 #[tokio::test]
193 async fn read_file_not_found_is_soft_error() {
194 let (_dir, registry, root) = setup();
195 let out = registry
196 .dispatch(
197 "read_file",
198 json!({ "target_file": "nope.txt" }),
199 &ctx(&root),
200 )
201 .await;
202 assert!(!out.record.ok);
203 assert!(is_error(&out.tool_result));
204 }
205
206 #[tokio::test]
207 async fn read_file_outside_jail_is_soft_error() {
208 let (_dir, registry, root) = setup();
209 let out = registry
210 .dispatch(
211 "read_file",
212 json!({ "target_file": "/etc/passwd" }),
213 &ctx(&root),
214 )
215 .await;
216 assert!(!out.record.ok);
217 assert!(is_error(&out.tool_result));
218 }
219
220 async fn edit(
223 registry: &Registry,
224 root: &Path,
225 args: serde_json::Value,
226 ) -> locode_tools::Dispatched {
227 registry.dispatch("search_replace", args, &ctx(root)).await
228 }
229
230 #[tokio::test]
231 async fn search_replace_creates_file_on_empty_old_string() {
232 let (_dir, registry, root) = setup();
233 let out = edit(
234 ®istry,
235 &root,
236 json!({ "file_path": "new.txt", "old_string": "", "new_string": "hello world" }),
237 )
238 .await;
239 assert!(out.record.ok);
240 assert_eq!(out.record.output["created"], json!(true));
241 assert_eq!(
242 std::fs::read_to_string(root.join("new.txt")).unwrap(),
243 "hello world"
244 );
245 }
246
247 #[tokio::test]
248 async fn search_replace_edits_unique_match() {
249 let (_dir, registry, root) = setup();
250 std::fs::write(root.join("f.txt"), "alpha beta gamma").unwrap();
251 let out = edit(
252 ®istry,
253 &root,
254 json!({ "file_path": "f.txt", "old_string": "beta", "new_string": "BETA" }),
255 )
256 .await;
257 assert!(out.record.ok);
258 assert_eq!(out.record.output["replacements"], json!(1));
259 assert_eq!(
260 std::fs::read_to_string(root.join("f.txt")).unwrap(),
261 "alpha BETA gamma"
262 );
263 }
264
265 #[tokio::test]
266 async fn search_replace_no_op_is_soft_error() {
267 let (_dir, registry, root) = setup();
268 std::fs::write(root.join("f.txt"), "x").unwrap();
269 let out = edit(
270 ®istry,
271 &root,
272 json!({ "file_path": "f.txt", "old_string": "x", "new_string": "x" }),
273 )
274 .await;
275 assert!(!out.record.ok);
276 assert!(result_text(&out.tool_result).contains("same"));
277 }
278
279 #[tokio::test]
280 async fn search_replace_not_found_is_soft_error() {
281 let (_dir, registry, root) = setup();
282 std::fs::write(root.join("f.txt"), "abc").unwrap();
283 let out = edit(
284 ®istry,
285 &root,
286 json!({ "file_path": "f.txt", "old_string": "zzz", "new_string": "q" }),
287 )
288 .await;
289 assert!(!out.record.ok);
290 assert!(result_text(&out.tool_result).contains("not found"));
291 }
292
293 #[tokio::test]
294 async fn search_replace_multiple_matches_without_replace_all_is_soft_error() {
295 let (_dir, registry, root) = setup();
296 std::fs::write(root.join("f.txt"), "a a a").unwrap();
297 let out = edit(
298 ®istry,
299 &root,
300 json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b" }),
301 )
302 .await;
303 assert!(!out.record.ok);
304 assert!(result_text(&out.tool_result).contains("multiple times"));
305 }
306
307 #[tokio::test]
308 async fn search_replace_replace_all() {
309 let (_dir, registry, root) = setup();
310 std::fs::write(root.join("f.txt"), "a a a").unwrap();
311 let out = edit(
312 ®istry,
313 &root,
314 json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b", "replace_all": true }),
315 )
316 .await;
317 assert!(out.record.ok);
318 assert_eq!(out.record.output["replacements"], json!(3));
319 assert_eq!(
320 std::fs::read_to_string(root.join("f.txt")).unwrap(),
321 "b b b"
322 );
323 }
324
325 fn rg_present() -> bool {
328 std::process::Command::new("rg")
329 .arg("--version")
330 .output()
331 .is_ok()
332 }
333
334 #[tokio::test]
335 async fn grep_finds_matches() {
336 if !rg_present() {
337 return;
338 }
339 let (_dir, registry, root) = setup();
340 std::fs::write(root.join("a.txt"), "needle here\nother line\n").unwrap();
341 std::fs::write(root.join("b.txt"), "nothing\n").unwrap();
342 let out = registry
343 .dispatch("grep", json!({ "pattern": "needle" }), &ctx(&root))
344 .await;
345 assert!(out.record.ok);
346 assert_eq!(out.record.output["matched"], json!(true));
347 let text = result_text(&out.tool_result);
348 assert!(text.contains("needle"), "{text}");
349 assert!(text.contains("a.txt"), "{text}");
350 }
351
352 #[tokio::test]
353 async fn grep_no_match_is_soft_ok() {
354 if !rg_present() {
355 return;
356 }
357 let (_dir, registry, root) = setup();
358 std::fs::write(root.join("a.txt"), "hello\n").unwrap();
359 let out = registry
360 .dispatch("grep", json!({ "pattern": "zzzznotfound" }), &ctx(&root))
361 .await;
362 assert!(out.record.ok);
363 assert_eq!(out.record.output["matched"], json!(false));
364 assert!(result_text(&out.tool_result).contains("No matches"));
365 }
366
367 #[tokio::test]
370 async fn list_dir_walks_the_tree() {
371 let (_dir, registry, root) = setup();
372 std::fs::create_dir(root.join("src")).unwrap();
373 std::fs::write(root.join("src/main.rs"), "").unwrap();
374 std::fs::write(root.join("Cargo.toml"), "").unwrap();
375 let out = registry
376 .dispatch("list_dir", json!({ "target_directory": "." }), &ctx(&root))
377 .await;
378 assert!(out.record.ok);
379 let text = result_text(&out.tool_result);
380 assert!(text.contains("src/"), "{text}");
381 assert!(text.contains("main.rs"), "{text}");
382 assert!(text.contains("Cargo.toml"), "{text}");
383 }
384
385 #[tokio::test]
386 async fn list_dir_missing_is_soft_error() {
387 let (_dir, registry, root) = setup();
388 let out = registry
389 .dispatch(
390 "list_dir",
391 json!({ "target_directory": "nope" }),
392 &ctx(&root),
393 )
394 .await;
395 assert!(!out.record.ok);
396 assert!(is_error(&out.tool_result));
397 }
398}