1#![deny(unsafe_code)]
6
7use anyhow::{Context, Result, bail};
8pub mod shell_prompt;
9
10use serde_json::{Value, json};
11use std::path::{Path, PathBuf};
12
13pub mod bash_patterns;
14pub mod permission_asks;
15
16pub fn wildcard_match(s: &str, pattern: &str) -> bool {
18 let s = s.replace('\\', "/");
19 let pattern = pattern.replace('\\', "/");
20 let mut escaped = String::with_capacity(pattern.len() * 2);
21 for c in pattern.chars() {
22 match c {
23 '.' | '+' | '^' | '$' | '{' | '}' | '(' | ')' | '|' | '[' | ']' | '\\' => {
24 escaped.push('\\');
25 escaped.push(c);
26 }
27 '*' => escaped.push_str(".*"),
28 '?' => escaped.push('.'),
29 other => escaped.push(other),
30 }
31 }
32 if escaped.ends_with(" .*") {
34 escaped.truncate(escaped.len() - 3);
35 escaped.push_str("( .*)?");
36 }
37 let re = format!("^{escaped}$");
38 glob_to_regex_match(&s, &re)
39}
40
41fn glob_to_regex_match(s: &str, re_like: &str) -> bool {
44 regex_match(s.as_bytes(), re_like.as_bytes())
46}
47
48fn regex_match(s: &[u8], p: &[u8]) -> bool {
50 fn helper(s: &[u8], p: &[u8]) -> bool {
51 if p.is_empty() {
52 return s.is_empty();
53 }
54 match p[0] {
55 b'^' => helper(s, &p[1..]),
56 b'$' => p.len() == 1 && s.is_empty(),
57 b'.' if p.len() > 1 && p[1] == b'*' => {
59 for i in 0..=s.len() {
60 if helper(&s[i..], &p[2..]) {
61 return true;
62 }
63 }
64 false
65 }
66 b'.' => !s.is_empty() && helper(&s[1..], &p[1..]),
67 b'(' if p.starts_with(b"( .*)?") => {
68 let rest = &p[b"( .*)?".len()..];
70 if helper(s, rest) {
72 return true;
73 }
74 if s.is_empty() {
76 return false;
77 }
78 if s[0] != b' ' {
80 return false;
81 }
82 for i in 1..=s.len() {
83 if helper(&s[i..], rest) {
84 return true;
85 }
86 }
87 false
88 }
89 b'\\' if p.len() > 1 => !s.is_empty() && s[0] == p[1] && helper(&s[1..], &p[2..]),
90 lit => !s.is_empty() && s[0] == lit && helper(&s[1..], &p[1..]),
91 }
92 }
93 helper(s, p)
95}
96
97#[derive(Clone, Debug)]
100pub struct Rule {
101 pub permission: String,
102 pub pattern: String,
103 pub action: String,
104}
105
106pub fn evaluate(permission: &str, resource: &str, rules: &[Rule]) -> String {
107 rules
108 .iter()
109 .rev()
110 .find(|r| wildcard_match(permission, &r.permission) && wildcard_match(resource, &r.pattern))
111 .map(|r| r.action.clone())
112 .unwrap_or_else(|| "ask".to_string())
113}
114
115pub const MAX_LINES: usize = 2000;
117pub const MAX_BYTES: usize = 50 * 1024;
118
119pub struct ToolResult {
120 pub output: String,
121 pub truncated: bool,
122 pub exit: Option<i32>,
123 pub title: String,
124 pub error: bool,
125 pub metadata: Option<serde_json::Value>,
128}
129
130fn todowrite(input: &Value) -> Result<ToolResult> {
134 let todos = input
135 .get("todos")
136 .and_then(|t| t.as_array())
137 .ok_or_else(|| anyhow::anyhow!("todowrite.todos must be an array"))?;
138 let mut normalized = Vec::with_capacity(todos.len());
139 for t in todos {
140 let content = t
141 .get("content")
142 .and_then(|c| c.as_str())
143 .ok_or_else(|| anyhow::anyhow!("todo.content required"))?;
144 let status = t
145 .get("status")
146 .and_then(|x| x.as_str())
147 .unwrap_or("pending");
148 let priority = t.get("priority").and_then(|x| x.as_str()).unwrap_or("");
149 normalized.push(json!({
150 "content": content,
151 "status": status,
152 "priority": priority,
153 }));
154 }
155 let output = serde_json::to_string_pretty(&json!({"todos": normalized}))?;
156 Ok(ToolResult {
157 output,
158 truncated: false,
159 exit: None,
160 title: "todowrite".into(),
161 error: false,
162 metadata: Some(json!({"todos": normalized})),
163 })
164}
165
166pub const IMPLEMENTED: &[&str] = &[
171 "bash",
172 "edit",
173 "glob",
174 "grep",
175 "question",
176 "read",
177 "skill",
178 "todowrite",
179 "webfetch",
180 "write",
181];
182
183fn frozen_tools() -> Vec<Value> {
190 let raw: Value = serde_json::from_str(include_str!("../assets/provider-tools.json"))
191 .expect("vendored provider-tools.json is valid");
192 raw.as_array().cloned().unwrap_or_default()
193}
194
195pub fn schemas() -> Vec<Value> {
198 let (shell, tmp) = host_shell_and_tmp();
199 frozen_tools()
200 .into_iter()
201 .filter(|t| {
202 t.pointer("/function/name")
203 .and_then(|n| n.as_str())
204 .map(|n| IMPLEMENTED.contains(&n))
205 .unwrap_or(false)
206 })
207 .map(|mut t| {
208 if t.pointer("/function/name").and_then(|n| n.as_str()) == Some("bash") {
209 let desc = shell_prompt::bash_description(&shell, &tmp);
212 t["function"]["description"] = Value::String(desc);
213 }
214 t
215 })
216 .collect()
217}
218
219fn host_shell_and_tmp() -> (String, String) {
222 let shell = std::env::var("SHELL")
223 .ok()
224 .filter(|s| !s.is_empty())
225 .and_then(|s| {
226 std::path::Path::new(&s)
227 .file_name()
228 .map(|f| f.to_string_lossy().into_owned())
229 })
230 .unwrap_or_else(|| "sh".to_string());
231 let tmp = std::env::temp_dir()
232 .join("opencode")
233 .to_string_lossy()
234 .into_owned();
235 (shell, tmp)
236}
237
238pub const QUESTION_DESCRIPTION: &str = "Use this tool when you need to ask the user questions during execution. This allows you to:
2421. Gather user preferences or requirements
2432. Clarify ambiguous instructions
2443. Get decisions on implementation choices as you work
2454. Offer choices to the user about what direction to take.
246
247Usage notes:
248- When `custom` is enabled (default), a \"Type your own answer\" option is added automatically; don't include \"Other\" or catch-all options
249- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one
250- If you recommend a specific option, make that the first option in the list and add \"(Recommended)\" at the end of the label";
251
252fn drain_limited<R: std::io::Read + Send + 'static>(reader: Option<R>, quota: usize) -> Vec<u8> {
257 let Some(mut r) = reader else {
258 return Vec::new();
259 };
260 let mut out: Vec<u8> = Vec::with_capacity(quota.min(64 * 1024));
261 let mut total = 0usize;
262 let mut buf = [0u8; 16 * 1024];
263 loop {
264 match r.read(&mut buf) {
265 Ok(0) => break,
266 Ok(n) => {
267 if total < quota {
268 let take = n.min(quota - total);
269 out.extend_from_slice(&buf[..take]);
270 total += take;
271 }
272 }
274 Err(_) => break,
275 }
276 }
277 out
278}
279
280fn truncate(mut output: String) -> (String, bool) {
281 let mut truncated = false;
282 if output.len() > MAX_BYTES {
283 let mut cut = MAX_BYTES;
288 while cut > 0 && !output.is_char_boundary(cut) {
289 cut -= 1;
290 }
291 output.truncate(cut);
292 truncated = true;
293 }
294 let lines = output.split('\n').count();
295 if lines > MAX_LINES {
296 let keep: String = output
297 .split('\n')
298 .take(MAX_LINES)
299 .collect::<Vec<_>>()
300 .join("\n");
301 output = keep;
302 truncated = true;
303 }
304 (output, truncated)
305}
306
307pub fn execute(name: &str, input: &Value, cwd: &Path) -> Result<ToolResult> {
309 match name {
310 "bash" => bash(input, cwd),
311 "read" => read(input, cwd),
312 "write" => write(input, cwd),
313 "edit" => edit(input, cwd),
314 "glob" => glob(input, cwd),
315 "grep" => grep(input, cwd),
316 "todowrite" => todowrite(input),
317 "webfetch" => webfetch(input),
318 other => bail!("unknown tool: {other}"),
319 }
320}
321
322fn resolve(cwd: &Path, p: &str) -> PathBuf {
323 let path = Path::new(p);
324 if path.is_absolute() {
325 path.to_path_buf()
326 } else {
327 cwd.join(path)
328 }
329}
330
331#[allow(unsafe_code)] fn bash(input: &Value, cwd: &Path) -> Result<ToolResult> {
333 let command = input["command"].as_str().context("bash.command")?;
334 let timeout_ms = input["timeout"].as_u64().unwrap_or(120_000).min(600_000);
335 let mut cmd = std::process::Command::new("sh");
336 cmd.arg("-c")
337 .arg(command)
338 .current_dir(cwd)
339 .stdin(std::process::Stdio::null())
340 .stdout(std::process::Stdio::piped())
341 .stderr(std::process::Stdio::piped());
342 #[cfg(unix)]
349 {
350 use std::os::unix::process::CommandExt;
351 cmd.process_group(0);
352 }
353 let mut child = cmd.spawn().with_context(|| format!("spawn: {command}"))?;
354 let so = child.stdout.take();
358 let se = child.stderr.take();
359 let h_out = std::thread::spawn(move || drain_limited(so, MAX_BYTES + 1));
362 let h_err = std::thread::spawn(move || drain_limited(se, MAX_BYTES + 1));
363 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
365 let mut timed_out = false;
366 let mut exit_status: Option<std::process::ExitStatus> = None;
367 loop {
368 match child.try_wait() {
369 Ok(Some(st)) => {
370 exit_status = Some(st);
371 break;
372 }
373 Ok(None) => {
374 if std::time::Instant::now() >= deadline {
375 timed_out = true;
376 let _ = child.kill();
377 #[cfg(unix)]
378 {
379 unsafe {
385 libc::killpg(child.id() as i32, libc::SIGKILL);
386 }
387 }
388 let _ = child.wait();
389 break;
390 }
391 std::thread::sleep(std::time::Duration::from_millis(25));
392 }
393 Err(e) => bail!("wait: {e}"),
394 }
395 }
396 let stdout_bytes = h_out.join().unwrap_or_default();
397 let stderr_bytes = h_err.join().unwrap_or_default();
398 let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
399 let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
400 if timed_out {
401 if !stdout.ends_with('\n') && !stdout.is_empty() {
402 stdout.push('\n');
403 }
404 stdout.push_str(&format!("[timeout after {timeout_ms}ms]"));
405 }
406 if !stderr.is_empty() {
407 if !stdout.is_empty() && !stdout.ends_with('\n') {
408 stdout.push('\n');
409 }
410 stdout.push_str(&stderr);
411 }
412 let (output, truncated) = truncate(stdout);
413 let code = match exit_status {
414 Some(st) => st.code().unwrap_or(-1),
415 None => 124, };
417 Ok(ToolResult {
418 output,
419 truncated,
420 exit: Some(code),
421 title: shell_title(command),
422 error: timed_out || code != 0,
423 metadata: None,
424 })
425}
426
427fn shell_title(command: &str) -> String {
429 command.lines().next().unwrap_or(command).to_string()
430}
431
432fn read(input: &Value, cwd: &Path) -> Result<ToolResult> {
433 let fp = input["filePath"].as_str().context("read.filePath")?;
434 let path = resolve(cwd, fp);
435 let content =
436 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
437 let offset = input["offset"].as_u64().unwrap_or(0) as usize;
438 let limit = input["limit"].as_u64().map(|l| l as usize);
439 let lines: Vec<&str> = content.split('\n').collect();
440 let slice = if offset >= lines.len() {
441 ""
442 } else {
443 let end = match limit {
444 Some(l) => offset.saturating_add(l).min(lines.len()),
448 None => lines.len(),
449 };
450 &lines[offset..end].join("\n")
451 };
452 let (output, truncated) = truncate(slice.to_string());
453 Ok(ToolResult {
454 output,
455 truncated,
456 exit: None,
457 title: fp.to_string(),
458 error: false,
459 metadata: None,
460 })
461}
462
463fn write(input: &Value, cwd: &Path) -> Result<ToolResult> {
464 let fp = input["filePath"].as_str().context("write.filePath")?;
465 let content = input["content"].as_str().context("write.content")?;
466 let path = resolve(cwd, fp);
467 if let Some(parent) = path.parent() {
468 std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
469 }
470 std::fs::write(&path, content).with_context(|| format!("write {}", path.display()))?;
471 Ok(ToolResult {
472 output: format!("Wrote {} bytes to {}", content.len(), fp),
473 truncated: false,
474 exit: None,
475 title: fp.to_string(),
476 error: false,
477 metadata: None,
478 })
479}
480
481fn edit(input: &Value, cwd: &Path) -> Result<ToolResult> {
482 let fp = input["filePath"].as_str().context("edit.filePath")?;
483 let old = input["oldString"]
489 .as_str()
490 .or_else(|| input["oldText"].as_str())
491 .context("edit.oldString")?;
492 let new = input["newString"]
493 .as_str()
494 .or_else(|| input["newText"].as_str())
495 .context("edit.newString")?;
496 let replace_all = input["replaceAll"].as_bool().unwrap_or(false);
497 let path = resolve(cwd, fp);
498 let content =
499 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
500 let hits = content.matches(old).count();
501 if hits == 0 {
502 bail!("oldString not found in {fp}");
503 }
504 if hits > 1 && !replace_all {
505 bail!("oldString matches {hits} times in {fp}; must be unique (or set replaceAll)");
506 }
507 let updated = if replace_all {
508 content.replace(old, new)
509 } else {
510 content.replacen(old, new, 1)
511 };
512 std::fs::write(&path, &updated).with_context(|| format!("write {}", path.display()))?;
513 Ok(ToolResult {
514 output: format!("Edited {fp}"),
515 truncated: false,
516 exit: None,
517 title: fp.to_string(),
518 error: false,
519 metadata: None,
520 })
521}
522
523fn glob(input: &Value, cwd: &Path) -> Result<ToolResult> {
524 let pattern = input["pattern"].as_str().context("glob.pattern")?;
525 let base = match input["path"].as_str() {
526 Some(p) => resolve(cwd, p),
527 None => cwd.to_path_buf(),
528 };
529 let mut matches = Vec::new();
530 walk(&base, &base, pattern, &mut matches, 0)?;
531 matches.sort();
532 let joined = matches.join("\n");
533 let (output, truncated) = truncate(joined);
534 Ok(ToolResult {
535 output,
536 truncated,
537 exit: None,
538 title: pattern.to_string(),
539 error: false,
540 metadata: None,
541 })
542}
543
544fn walk(base: &Path, dir: &Path, pattern: &str, out: &mut Vec<String>, depth: usize) -> Result<()> {
545 if depth > 32 || out.len() > 10_000 {
546 return Ok(());
547 }
548 let entries = match std::fs::read_dir(dir) {
549 Ok(e) => e,
550 Err(_) => return Ok(()),
551 };
552 for entry in entries.flatten() {
553 let path = entry.path();
554 let rel = path
555 .strip_prefix(base)
556 .unwrap_or(&path)
557 .to_string_lossy()
558 .to_string();
559 if path.is_dir() {
560 if entry.file_name() == ".git" {
561 continue;
562 }
563 walk(base, &path, pattern, out, depth + 1)?;
564 } else if wildcard_match(&rel, pattern) {
565 out.push(rel);
566 }
567 }
568 Ok(())
569}
570
571fn grep(input: &Value, cwd: &Path) -> Result<ToolResult> {
572 let pattern = input["pattern"].as_str().context("grep.pattern")?;
573 let base = match input["path"].as_str() {
574 Some(p) => resolve(cwd, p),
575 None => cwd.to_path_buf(),
576 };
577 let include = input["include"].as_str().map(String::from);
578 let re = grep_matcher(pattern)?;
579 let mut hits: Vec<String> = Vec::new();
580 grep_walk(&base, &base, &re, include.as_deref(), &mut hits, 0)?;
581 hits.sort();
582 let (output, truncated) = truncate(hits.join("\n"));
583 Ok(ToolResult {
584 output,
585 truncated,
586 exit: None,
587 title: pattern.to_string(),
588 error: false,
589 metadata: None,
590 })
591}
592
593fn webfetch(input: &Value) -> Result<ToolResult> {
599 let url = input["url"].as_str().context("webfetch.url")?;
600 let format = input["format"].as_str().unwrap_or("markdown");
601 let timeout_s = input["timeout"].as_u64().unwrap_or(30).min(120);
602 let url = match url.strip_prefix("http://") {
604 Some(rest) => format!("https://{rest}"),
605 None => url.to_string(),
606 };
607 let client = reqwest::blocking::Client::builder()
608 .timeout(std::time::Duration::from_secs(timeout_s))
609 .user_agent("ocserve/1.0")
610 .build()
611 .context("webfetch client")?;
612 let resp = client.get(&url).send().context("webfetch request")?;
613 let status = resp.status();
614 if !status.is_success() {
615 bail!("webfetch {url}: HTTP {status}");
616 }
617 let body = resp.text().context("webfetch body")?;
618 let out = match format {
619 "html" => body,
620 "text" | "markdown" => html_to_text(&body),
621 _ => html_to_text(&body),
622 };
623 let (output, truncated) = truncate(out);
624 Ok(ToolResult {
625 output,
626 truncated,
627 exit: None,
628 title: url,
629 error: false,
630 metadata: None,
631 })
632}
633
634fn html_to_text(html: &str) -> String {
637 let no_script = strip_blocks(html, "script");
638 let no_style = strip_blocks(&no_script, "style");
639 let mut out = String::with_capacity(no_style.len());
640 let mut in_tag = false;
641 for c in no_style.chars() {
642 match c {
643 '<' => in_tag = true,
644 '>' => {
645 in_tag = false;
646 out.push(' ');
647 }
648 _ if !in_tag => out.push(c),
649 _ => {}
650 }
651 }
652 collapse_lines(&decode_entities(&out))
653}
654
655fn strip_blocks(html: &str, tag: &str) -> String {
656 let open = format!("<{tag}");
657 let close = format!("</{tag}>");
658 let mut out = String::with_capacity(html.len());
659 let mut rest = html;
660 loop {
661 match rest.find(&open) {
662 Some(i) => {
663 out.push_str(&rest[..i]);
664 match rest[i..].find(&close) {
665 Some(j) => rest = &rest[i + j + close.len()..],
666 None => break,
667 }
668 }
669 None => {
670 out.push_str(rest);
671 break;
672 }
673 }
674 }
675 out
676}
677
678fn decode_entities(s: &str) -> String {
679 s.replace("&", "&")
680 .replace("<", "<")
681 .replace(">", ">")
682 .replace(""", "\"")
683 .replace("'", "'")
684 .replace(" ", " ")
685}
686
687fn collapse_lines(s: &str) -> String {
688 let mut out = String::with_capacity(s.len());
689 let mut blank = 0;
690 for line in s.lines() {
691 let t = line.trim();
692 if t.is_empty() {
693 blank += 1;
694 if blank > 1 {
695 continue;
696 }
697 } else {
698 blank = 0;
699 }
700 out.push_str(t);
701 out.push('\n');
702 }
703 out.trim_end().to_string()
704}
705
706fn grep_matcher(pattern: &str) -> Result<String> {
709 Ok(pattern.to_string())
710}
711
712fn grep_walk(
713 base: &Path,
714 dir: &Path,
715 pattern: &str,
716 include: Option<&str>,
717 out: &mut Vec<String>,
718 depth: usize,
719) -> Result<()> {
720 if depth > 32 || out.len() > 10_000 {
721 return Ok(());
722 }
723 let entries = match std::fs::read_dir(dir) {
724 Ok(e) => e,
725 Err(_) => return Ok(()),
726 };
727 for entry in entries.flatten() {
728 let path = entry.path();
729 if path.is_dir() {
730 if entry.file_name() == ".git" {
731 continue;
732 }
733 grep_walk(base, &path, pattern, include, out, depth + 1)?;
734 continue;
735 }
736 let rel = path
737 .strip_prefix(base)
738 .unwrap_or(&path)
739 .to_string_lossy()
740 .to_string();
741 if let Some(inc) = include
742 && !wildcard_match(&rel, inc)
743 {
744 continue;
745 }
746 let Ok(content) = std::fs::read_to_string(&path) else {
747 continue;
748 };
749 for (i, line) in content.split('\n').enumerate() {
750 if regex_search_line(line, pattern) {
751 out.push(format!("{}:{}:{}", rel, i + 1, line));
752 if out.len() > 10_000 {
753 return Ok(());
754 }
755 }
756 }
757 }
758 Ok(())
759}
760
761fn regex_search_line(line: &str, pattern: &str) -> bool {
764 use std::process::Command;
765 let out = Command::new("grep")
766 .arg("-E")
767 .arg("-q")
768 .arg(pattern)
769 .arg("-")
770 .stdin(std::process::Stdio::piped())
771 .stdout(std::process::Stdio::null())
772 .spawn();
773 match out {
774 Ok(mut child) => {
775 use std::io::Write;
776 if let Some(stdin) = child.stdin.as_mut() {
777 let _ = stdin.write_all(line.as_bytes());
778 let _ = stdin.flush();
779 }
780 drop(child.stdin.take());
781 child.wait().map(|s| s.success()).unwrap_or(false)
782 }
783 Err(_) => line.contains(pattern),
784 }
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790
791 #[test]
792 fn wildcard_quirks_ported() {
793 assert!(wildcard_match("ls", "ls *"));
795 assert!(wildcard_match("ls -la", "ls *"));
796 assert!(wildcard_match("*.env", "*.env"));
798 assert!(wildcard_match(".env", "*.env"));
799 assert!(wildcard_match("a/b.txt", "a/*.txt"));
800 assert!(!wildcard_match("a/b.txt", "a/*.md"));
801 assert!(wildcard_match("file(1).txt", "file(1).txt"));
803 assert!(wildcard_match("abc", "a?c"));
805 assert!(!wildcard_match("abbc", "a?c"));
806 }
807
808 #[test]
809 fn evaluate_last_match_wins_and_default_ask() {
810 let rules = vec![
811 Rule {
812 permission: "*".into(),
813 pattern: "*".into(),
814 action: "allow".into(),
815 },
816 Rule {
817 permission: "edit".into(),
818 pattern: "*.env".into(),
819 action: "deny".into(),
820 },
821 ];
822 assert_eq!(evaluate("bash", "rm -rf /", &rules), "allow");
823 assert_eq!(evaluate("edit", "/x/.env", &rules), "deny");
824 assert_eq!(evaluate("question", "x", &[]), "ask");
826 }
827
828 #[test]
829 fn bash_echo() {
830 let r = execute("bash", &json!({"command": "echo TOOL_OK"}), Path::new("/")).unwrap();
831 assert_eq!(r.output.trim(), "TOOL_OK");
832 assert_eq!(r.exit, Some(0));
833 assert!(!r.error);
834 assert_eq!(r.title, "echo TOOL_OK");
835 }
836
837 #[test]
838 fn bash_exit_code_and_stderr_merge() {
839 let r = execute(
840 "bash",
841 &json!({"command": "echo OOPS >&2; exit 3"}),
842 Path::new("/"),
843 )
844 .unwrap();
845 assert_eq!(r.exit, Some(3));
846 assert!(r.error);
847 assert!(r.output.contains("OOPS"));
848 }
849
850 #[test]
851 fn bash_timeout_enforced() {
852 let t0 = std::time::Instant::now();
853 let r = execute(
854 "bash",
855 &json!({"command": "sleep 10", "timeout": 300}),
856 Path::new("/"),
857 )
858 .unwrap();
859 assert_eq!(r.exit, Some(124));
860 assert!(r.error);
861 assert!(t0.elapsed().as_secs() < 5, "timeout must bound the wait");
862 }
863
864 #[test]
865 fn todowrite_normalizes_and_reports() {
866 let r = execute(
867 "todowrite",
868 &json!({"todos": [
869 {"content": "step one", "status": "in_progress"},
870 {"content": "step two", "status": "pending", "priority": "high"}
871 ]}),
872 Path::new("/"),
873 )
874 .unwrap();
875 let meta = r.metadata.expect("todos metadata for the part state");
876 assert_eq!(meta["todos"][0]["status"], "in_progress");
877 assert_eq!(meta["todos"][1]["priority"], "high");
878 assert_eq!(meta["todos"][0]["priority"], "", "priority defaults empty");
879 assert!(r.output.contains("step one"));
880 assert_eq!(r.title, "todowrite");
881 assert!(!r.error);
882 assert!(execute("todowrite", &json!({"nope": []}), Path::new("/")).is_err());
884 }
885
886 #[test]
887 fn truncation_bounds() {
888 let big = "x".repeat(MAX_BYTES + 100);
889 let (out, t) = truncate(big);
890 assert!(t);
891 assert!(out.len() <= MAX_BYTES);
892 let many = "\n".repeat(MAX_LINES + 50);
893 let (out2, t2) = truncate(many);
894 assert!(t2);
895 assert!(out2.split('\n').count() <= MAX_LINES + 1);
896 }
897
898 #[test]
904 fn truncate_never_panics_on_multibyte_boundary() {
905 let mut s = "a".repeat(MAX_BYTES - 1);
907 s.push('€'); s.push_str(&"b".repeat(100));
909 let (out, t) = truncate(s);
910 assert!(t);
911 assert!(out.len() <= MAX_BYTES);
912 assert!(out.is_char_boundary(out.len()));
913 }
914
915 #[test]
916 fn read_write_edit_roundtrip() {
917 let d = tempfile::tempdir().unwrap();
918 let fp = d.path().join("f.txt");
919 execute(
920 "write",
921 &json!({"filePath": fp.to_str().unwrap(), "content": "hello world"}),
922 d.path(),
923 )
924 .unwrap();
925 let r = execute("read", &json!({"filePath": fp.to_str().unwrap()}), d.path()).unwrap();
926 assert_eq!(r.output, "hello world");
927 execute(
928 "edit",
929 &json!({"filePath": fp.to_str().unwrap(), "oldText": "world", "newText": "ocserve"}),
930 d.path(),
931 )
932 .unwrap();
933 let r2 = execute("read", &json!({"filePath": fp.to_str().unwrap()}), d.path()).unwrap();
934 assert_eq!(r2.output, "hello ocserve");
935 let e = execute(
937 "edit",
938 &json!({"filePath": fp.to_str().unwrap(), "oldText": "e", "newText": "E"}),
939 d.path(),
940 );
941 assert!(e.is_err(), "non-unique oldText must fail");
942 }
943
944 #[test]
945 fn glob_and_grep() {
946 let d = tempfile::tempdir().unwrap();
947 std::fs::write(d.path().join("a.rs"), "fn main() {}\n").unwrap();
948 std::fs::create_dir(d.path().join("sub")).unwrap();
949 std::fs::write(d.path().join("sub/b.rs"), "fn helper() {}\n").unwrap();
950 let g = execute("glob", &json!({"pattern": "*.rs"}), d.path()).unwrap();
951 assert!(
952 g.output.contains("a.rs") && g.output.contains("sub/b.rs"),
953 "{}",
954 g.output
955 );
956 let gr = execute("grep", &json!({"pattern": "helper"}), d.path()).unwrap();
957 assert!(gr.output.contains("sub/b.rs:1"), "{}", gr.output);
958 let gr2 = execute(
959 "grep",
960 &json!({"pattern": "main", "include": "*.rs"}),
961 d.path(),
962 )
963 .unwrap();
964 assert!(gr2.output.contains("a.rs"), "{}", gr2.output);
965 }
966
967 #[test]
968 fn schemas_are_frozen_and_filtered_to_implemented() {
969 let s = schemas();
975 let frozen: Vec<Value> =
976 serde_json::from_str(include_str!("../assets/provider-tools.json")).unwrap();
977 let frozen_by: std::collections::BTreeMap<String, &Value> = frozen
978 .iter()
979 .map(|t| (t["function"]["name"].as_str().unwrap().to_string(), t))
980 .collect();
981 let mut emitted: Vec<String> = Vec::new();
982 for spec in &s {
983 let name = spec["function"]["name"].as_str().unwrap().to_string();
984 let frozen = frozen_by.get(&name).unwrap();
985 assert_eq!(
987 &spec["function"]["parameters"], &frozen["function"]["parameters"],
988 "{name} params diverged from the frozen capture"
989 );
990 if name == "bash" {
991 let (shell, tmp) = host_shell_and_tmp();
994 let want = shell_prompt::bash_description(&shell, &tmp);
995 assert_eq!(spec["function"]["description"], serde_json::json!(want));
996 } else {
997 assert_eq!(
998 &spec["function"]["description"], &frozen["function"]["description"],
999 "{name} description diverged from the frozen capture"
1000 );
1001 }
1002 emitted.push(name);
1003 }
1004 let mut want: Vec<String> = IMPLEMENTED
1005 .iter()
1006 .filter(|n| frozen_by.contains_key(**n))
1007 .map(|n| n.to_string())
1008 .collect();
1009 want.sort();
1010 emitted.sort();
1011 assert_eq!(emitted, want);
1012 let q = frozen_by.get("question").unwrap();
1014 let params = &q["function"]["parameters"];
1015 assert_eq!(params["required"][0], "questions");
1016 assert!(
1017 q["function"]["description"]
1018 .as_str()
1019 .unwrap()
1020 .contains("Type your own answer")
1021 );
1022 }
1023}
1024
1025#[cfg(test)]
1026mod drain_tests {
1027 use super::*;
1028 use std::path::Path;
1029
1030 #[test]
1038 fn huge_output_completes_unblocked_and_stays_bounded() {
1039 let input = json!({
1040 "command": "head -c 2000000 /dev/zero | tr '\\0' 'a'",
1041 "timeout": 5000,
1042 });
1043 let r = bash(&input, Path::new("/tmp")).expect("run");
1044 assert_eq!(
1045 r.exit,
1046 Some(0),
1047 "child must complete — pipes drained concurrently (old code: stall → timeout kill)"
1048 );
1049 assert!(
1050 !r.output.contains("[timeout"),
1051 "no timeout marker: {:?}",
1052 &r.output[..80.min(r.output.len())]
1053 );
1054 assert!(r.truncated, "output capped");
1055 assert!(r.output.len() <= MAX_BYTES, "bounded: {}", r.output.len());
1056 }
1057
1058 #[test]
1060 fn small_output_untouched() {
1061 let r = bash(&json!({"command": "printf 'hello\n'"}), Path::new("/tmp")).unwrap();
1062 assert_eq!(r.exit, Some(0));
1063 assert_eq!(r.output.trim(), "hello");
1064 assert!(!r.truncated);
1065 }
1066}