1 //! Agent tools: schema definitions + execution for siGit Code.
2
3 use regex::Regex;
4 use serde_json::{Value, json};
5 use std::collections::HashMap;
6 use std::fs;
7 use std::path::{Path, PathBuf};
8 use std::process::Command;
9 use std::sync::atomic::{AtomicU64, Ordering};
10 use std::sync::{Arc, Mutex, OnceLock};
11
12 use crate::backend::{InferenceBackend, ToolResult, ToolSpec};
13
14 const WEBSITE_READ_CHAR_LIMIT: usize = 20_000;
15 const WEBSITE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
16 const WEBSITE_USER_AGENT: &str =
17 "siGit/0.1 (+https://github.com/getsigit/sigit; website-reading tool)";
18
19 const READ_FILE_CHAR_LIMIT: usize = 10_000;
20 const SEARCH_FILES_MATCH_LIMIT: usize = 50;
21 /// Upper bound on `max_results` for `search_files` and the number of paths
22 /// returned by `glob`, so a broad pattern can't flood the context window.
23 const SEARCH_RESULTS_HARD_CAP: usize = 1_000;
24
25 // ── Tool schemas ─────────────────────────────────────────────────────────────
26
27 pub struct AgentTool {
28 pub name: &'static str,
29 pub description: &'static str,
30 pub parameters_schema: Value,
31 }
32
33 pub fn all_tools() -> Vec<AgentTool> {
34 vec![
35 AgentTool {
36 name: "read_file",
37 description: "Read the contents of a file at the given path. \
38 Prefer an absolute path when possible. Use start_line and \
39 end_line to read a specific range instead of the whole file — \
40 strongly prefer this when you already know which lines matter. \
41 Output is truncated to 10 000 characters.",
42 parameters_schema: json!({
43 "type": "object",
44 "properties": {
45 "path": {
46 "type": "string",
47 "description": "Absolute or relative path to the file to read."
48 },
49 "start_line": {
50 "type": "integer",
51 "description": "First line to read (1-based, inclusive). Omit to start from the beginning."
52 },
53 "end_line": {
54 "type": "integer",
55 "description": "Last line to read (1-based, inclusive). Omit to read to the end."
56 }
57 },
58 "required": ["path"],
59 "additionalProperties": false
60 }),
61 },
62 AgentTool {
63 name: "create_directory",
64 description: "Create a directory at the given path. \
65 Prefer an absolute path when possible. Missing parent \
66 directories are created automatically. Use this before \
67 create_file when the parent path does not exist. Succeeds \
68 if the directory already exists.",
69 parameters_schema: json!({
70 "type": "object",
71 "properties": {
72 "path": {
73 "type": "string",
74 "description": "Absolute or relative path to the directory to create."
75 }
76 },
77 "required": ["path"],
78 "additionalProperties": false
79 }),
80 },
81 AgentTool {
82 name: "list_directory",
83 description: "List files and directories at the given path. \
84 Prefer an absolute path when possible. Each entry is \
85 prefixed with [DIR] or [FILE]. Directories are listed \
86 first, sorted alphabetically.",
87 parameters_schema: json!({
88 "type": "object",
89 "properties": {
90 "path": {
91 "type": "string",
92 "description": "Absolute or relative path to the directory to list."
93 }
94 },
95 "required": ["path"],
96 "additionalProperties": false
97 }),
98 },
99 AgentTool {
100 name: "search_files",
101 description: "Search for a regex pattern across files in a directory tree. \
102 Prefer an absolute root path when possible. Returns matching \
103 lines in `file:line_number: content` format. Skips binary \
104 files and hidden directories. Pass `file_glob` to restrict the \
105 search to files whose name matches a glob (e.g. \"*.rs\"), and \
106 `max_results` to raise or lower the default cap of 50 matches.",
107 parameters_schema: json!({
108 "type": "object",
109 "properties": {
110 "pattern": {
111 "type": "string",
112 "description": "Regular expression pattern to search for."
113 },
114 "path": {
115 "type": "string",
116 "description": "Root directory to search in. Defaults to \".\" (current directory)."
117 },
118 "file_glob": {
119 "type": "string",
120 "description": "Optional glob on the file name (not the full path), e.g. \"*.rs\" or \"*.{ts,tsx}\". Only matching files are searched."
121 },
122 "max_results": {
123 "type": "integer",
124 "description": "Maximum number of matching lines to return (default 50, capped at 1000)."
125 }
126 },
127 "required": ["pattern"],
128 "additionalProperties": false
129 }),
130 },
131 AgentTool {
132 name: "read_website",
133 description: "Fetch a web page and return readable text content. \
134 Use this when the user gives you a URL and asks you to read, \
135 summarize, inspect, or extract information from the page. \
136 Supports normal http and https URLs. Output is truncated if the \
137 page is very large.",
138 parameters_schema: json!({
139 "type": "object",
140 "properties": {
141 "url": {
142 "type": "string",
143 "description": "Absolute http or https URL to fetch."
144 }
145 },
146 "required": ["url"],
147 "additionalProperties": false
148 }),
149 },
150 AgentTool {
151 name: "create_file",
152 description: "Create a new file at the given path with the provided content. \
153 Prefer an absolute path when possible. Parent directories are \
154 created automatically if they do not exist. Fails if the file \
155 already exists — use edit_file to modify existing files.",
156 parameters_schema: json!({
157 "type": "object",
158 "properties": {
159 "path": {
160 "type": "string",
161 "description": "Absolute or relative path for the new file."
162 },
163 "content": {
164 "type": "string",
165 "description": "The full text content to write into the new file."
166 }
167 },
168 "required": ["path", "content"],
169 "additionalProperties": false
170 }),
171 },
172 AgentTool {
173 name: "edit_file",
174 description: "Edit an existing file by replacing an exact substring (old_text) with \
175 new text (new_text). Prefer an absolute path when possible. By \
176 default old_text must appear exactly once; set replace_all to true \
177 to replace every occurrence (useful for renaming a symbol). Use \
178 read_file first to see the current content and identify the exact \
179 text to replace. To append to a file, match the last few lines as \
180 old_text and include them plus the new content as new_text.",
181 parameters_schema: json!({
182 "type": "object",
183 "properties": {
184 "path": {
185 "type": "string",
186 "description": "Path to the existing file to edit."
187 },
188 "old_text": {
189 "type": "string",
190 "description": "The exact text span to find and replace. Must match exactly once unless replace_all is true."
191 },
192 "new_text": {
193 "type": "string",
194 "description": "The replacement text that will take the place of old_text."
195 },
196 "replace_all": {
197 "type": "boolean",
198 "description": "Replace every occurrence of old_text instead of requiring a unique match. Defaults to false."
199 }
200 },
201 "required": ["path", "old_text", "new_text"],
202 "additionalProperties": false
203 }),
204 },
205 AgentTool {
206 name: "delete_file",
207 description: "Delete a file or empty directory at the given path. \
208 Prefer an absolute path when possible. Refuses to delete \
209 non-empty directories to prevent accidental data loss. \
210 Use read_file or list_directory first to confirm the target.",
211 parameters_schema: json!({
212 "type": "object",
213 "properties": {
214 "path": {
215 "type": "string",
216 "description": "Absolute or relative path to the file or empty directory to delete."
217 }
218 },
219 "required": ["path"],
220 "additionalProperties": false
221 }),
222 },
223 AgentTool {
224 name: "run_command",
225 description: "Run a shell command and return its combined stdout and stderr output. \
226 The command runs in the given working directory (defaults to the \
227 user's home directory). Always use an absolute working directory \
228 path. Use this for build tools (cargo, npm, make), package managers, \
229 linters, test runners, and git commands, including git init, \
230 porcelain commands like status/add/commit/checkout, and plumbing \
231 commands like rev-parse, hash-object, update-ref, and cat-file. \
232 For `git clone`, always specify the full absolute destination path \
233 as the last argument (e.g. `git clone <url> /absolute/path/to/dir`) \
234 and set cwd to the parent directory. Never run `git clone` without \
235 an explicit destination. If the user asks for a new repo or scaffold, \
236 use this for `git clone`, `git init`, and normal repo setup steps. \
237 In smbCloud repos, prefer existing workspace commands, Rails \
238 conventions, and deploy flows over inventing new command sequences. \
239 Foreground commands are killed after 120 seconds — run servers, \
240 watchers, builds, test suites, and anything that may exceed two \
241 minutes with run_in_background set to true, then poll with \
242 command_output.",
243 parameters_schema: json!({
244 "type": "object",
245 "properties": {
246 "command": {
247 "type": "string",
248 "description": "The shell command to execute (e.g. \"cargo update\", \"git status\", \"git rev-parse HEAD\")."
249 },
250 "cwd": {
251 "type": "string",
252 "description": "Working directory for the command. Defaults to \".\" (current directory)."
253 },
254 "run_in_background": {
255 "type": "boolean",
256 "description": "Start the command as a background task and return a task id immediately instead of waiting for it to finish. Set this to true for servers, watchers, builds, test suites, and anything that may run longer than two minutes (foreground commands are killed after 120 seconds). Poll the task's output and status with command_output, and stop it with kill_command. Background tasks are killed when sigit exits. Defaults to false."
257 }
258 },
259 "required": ["command"],
260 "additionalProperties": false
261 }),
262 },
263 AgentTool {
264 name: "multi_edit",
265 description: "Apply several exact-substring edits to a single file in one call. \
266 Edits are applied in order, each to the result of the previous one, \
267 and the whole batch is atomic — if any edit fails to match, the file \
268 is left untouched and an error explains which edit failed. Prefer \
269 this over multiple edit_file calls when changing several spots in the \
270 same file. Each edit has old_text (must match exactly once, or every \
271 time when replace_all is true) and new_text.",
272 parameters_schema: json!({
273 "type": "object",
274 "properties": {
275 "path": {
276 "type": "string",
277 "description": "Path to the existing file to edit."
278 },
279 "edits": {
280 "type": "array",
281 "description": "Ordered list of edits to apply to the file.",
282 "items": {
283 "type": "object",
284 "properties": {
285 "old_text": {
286 "type": "string",
287 "description": "The exact text span to find and replace."
288 },
289 "new_text": {
290 "type": "string",
291 "description": "The replacement text."
292 },
293 "replace_all": {
294 "type": "boolean",
295 "description": "Replace every occurrence instead of requiring a unique match. Defaults to false."
296 }
297 },
298 "required": ["old_text", "new_text"],
299 "additionalProperties": false
300 }
301 }
302 },
303 "required": ["path", "edits"],
304 "additionalProperties": false
305 }),
306 },
307 AgentTool {
308 name: "glob",
309 description: "Find files by name using a glob pattern (e.g. \"**/*.rs\", \
310 \"src/**/*.{ts,tsx}\", \"Cargo.toml\"). Returns matching file paths, \
311 most-recently-modified first. Supports `*` (any run of non-separator \
312 characters), `**` (any number of directories), `?` (one character), \
313 and `{a,b}` alternation. Use this to locate files by name; use \
314 search_files to search file contents.",
315 parameters_schema: json!({
316 "type": "object",
317 "properties": {
318 "pattern": {
319 "type": "string",
320 "description": "Glob pattern matched against paths relative to the search root."
321 },
322 "path": {
323 "type": "string",
324 "description": "Root directory to search in. Defaults to \".\" (current directory)."
325 }
326 },
327 "required": ["pattern"],
328 "additionalProperties": false
329 }),
330 },
331 AgentTool {
332 name: "write_todos",
333 description: "Record or update a checklist of the steps for the current task. \
334 Use this for any multi-step task to plan the work and show progress: \
335 call it once up front with all the steps as `pending`, then call it \
336 again whenever a step's status changes. Mark exactly one step \
337 `in_progress` at a time and `completed` as soon as it is done. \
338 Keep the list short and outcome-focused.",
339 parameters_schema: json!({
340 "type": "object",
341 "properties": {
342 "todos": {
343 "type": "array",
344 "description": "The full, current checklist (replaces any previous list).",
345 "items": {
346 "type": "object",
347 "properties": {
348 "content": {
349 "type": "string",
350 "description": "Short imperative description of the step."
351 },
352 "status": {
353 "type": "string",
354 "enum": ["pending", "in_progress", "completed"],
355 "description": "Current status of the step."
356 }
357 },
358 "required": ["content", "status"],
359 "additionalProperties": false
360 }
361 }
362 },
363 "required": ["todos"],
364 "additionalProperties": false
365 }),
366 },
367 AgentTool {
368 name: "remember",
369 description: "Persist a durable note, preference, or convention by appending it to \
370 this project's instruction file (AGENTS.md / CLAUDE.md). Use this \
371 when the user asks you to remember something for next time, or states \
372 a lasting preference about how to work in this project. The note is \
373 written to the nearest existing instruction file, or a new CLAUDE.md \
374 at the repository root if none exists yet.",
375 parameters_schema: json!({
376 "type": "object",
377 "properties": {
378 "note": {
379 "type": "string",
380 "description": "The fact or preference to remember, phrased as a standalone instruction."
381 }
382 },
383 "required": ["note"],
384 "additionalProperties": false
385 }),
386 },
387 AgentTool {
388 name: "command_output",
389 description: "Get the output a background task (started with run_command's \
390 run_in_background) has produced since your last check, plus its \
391 status: still running, or exited with an exit code. Poll this \
392 periodically to follow builds, test suites, and servers. Between \
393 polls output is buffered up to 50 000 bytes per task; older \
394 output beyond that is dropped and the truncation is noted.",
395 parameters_schema: json!({
396 "type": "object",
397 "properties": {
398 "task_id": {
399 "type": "integer",
400 "description": "Id of the background task, as returned by run_command with run_in_background."
401 }
402 },
403 "required": ["task_id"],
404 "additionalProperties": false
405 }),
406 },
407 AgentTool {
408 name: "kill_command",
409 description: "Kill a background task started with run_command's run_in_background. \
410 Reports the tail of the task's unread output and confirms it was \
411 killed. Use this to stop servers or watchers you no longer need and \
412 runaway commands. Background tasks are also killed automatically \
413 when sigit exits.",
414 parameters_schema: json!({
415 "type": "object",
416 "properties": {
417 "task_id": {
418 "type": "integer",
419 "description": "Id of the background task, as returned by run_command with run_in_background."
420 }
421 },
422 "required": ["task_id"],
423 "additionalProperties": false
424 }),
425 },
426 ]
427 }
428
429 // ── Tool execution ───────────────────────────────────────────────────────────
430
431 pub async fn execute_tool(name: &str, arguments: &str) -> String {
432 // Settings are read once per call (not once per hook phase) so a
433 // deployment without any [hooks] configured pays one cheap TOML parse
434 // per tool call rather than two.
435 let hook_settings = crate::settings::load().hooks;
436 let cwd = std::env::current_dir().ok();
437
438 if let Some(cwd) = &cwd
439 && !hook_settings.pre_tool_use.is_empty()
440 {
441 crate::hooks::run_pre_tool_use_hooks(&hook_settings, name, arguments, cwd);
442 }
443
444 let result = execute_tool_impl(name, arguments).await;
445
446 if let Some(cwd) = &cwd
447 && !hook_settings.post_tool_use.is_empty()
448 {
449 crate::hooks::run_post_tool_use_hooks(&hook_settings, name, &result, cwd);
450 }
451
452 result
453 }
454
455 async fn execute_tool_impl(name: &str, arguments: &str) -> String {
456 match name {
457 "read_file" => exec_read_file(arguments),
458 "list_directory" => exec_list_directory(arguments),
459 "search_files" => exec_search_files(arguments),
460 "read_website" => {
461 // reqwest::blocking panics inside a tokio runtime, so run on the blocking pool.
462 let args = arguments.to_owned();
463 tokio::task::spawn_blocking(move || exec_read_website(&args))
464 .await
465 .unwrap_or_else(|err| format!("Error: read_website task failed: {err}"))
466 }
467 "create_directory" => exec_create_directory(arguments),
468 "create_file" => exec_create_file(arguments),
469 "edit_file" => exec_edit_file(arguments),
470 "multi_edit" => exec_multi_edit(arguments),
471 "glob" => exec_glob(arguments),
472 "write_todos" => exec_write_todos(arguments),
473 "remember" => exec_remember(arguments),
474 "delete_file" => exec_delete_file(arguments),
475 "run_command" => exec_run_command(arguments),
476 "command_output" => exec_command_output(arguments),
477 "kill_command" => exec_kill_command(arguments),
478 "skill" => crate::skills::activate_skill(arguments),
479 TASK_TOOL_NAME => exec_task(arguments).await,
480 // Tools discovered from MCP servers are namespaced `mcp__<server>__<tool>`
481 // and forwarded to the owning server.
482 _ if crate::mcp::is_mcp_tool(name) => crate::mcp::call_tool(name, arguments).await,
483 _ => format!("Unknown tool: {name}"),
484 }
485 }
486
487 // ── task (subagent) ──────────────────────────────────────────────────────────
488 //
489 // The `task` tool delegates a self-contained research question to a *nested*
490 // agent loop running in a fresh conversation, so the main thread receives only
491 // the final answer instead of every intermediate file read. The subagent's
492 // toolset is strictly read-only and never includes `task` itself (no
493 // recursion), so a delegated agent can research but not mutate state.
494 //
495 // This module is backend-agnostic and cannot construct a backend, so the
496 // surface that knows the active provider (`run_acp_server` / `run_interactive`
497 // in `main.rs`) registers a factory at startup. The factory returns `None`
498 // when inference runs on-device: onde has a single shared conversation
499 // history, so a second concurrent context is not possible yet.
500
501 pub const TASK_TOOL_NAME: &str = "task";
502
503 /// The tool names a subagent may call, filtered from [`all_tools`].
504 const SUBAGENT_TOOL_NAMES: &[&str] = &[
505 "read_file",
506 "list_directory",
507 "search_files",
508 "glob",
509 "read_website",
510 ];
511
512 /// System prompt seeding every subagent conversation.
513 pub const SUBAGENT_SYSTEM_PROMPT: &str = "You are a focused research subagent. \
514 You are given one self-contained task by a calling agent. Investigate it using \
515 the read-only tools available to you (read_file, list_directory, search_files, \
516 glob, read_website) and answer it thoroughly but concisely. You cannot modify \
517 files or run commands. Your final message is returned verbatim to the caller, \
518 so make it a complete, self-contained answer — include the concrete facts, \
519 paths, and code excerpts the caller needs, and nothing else.";
520
521 /// Rounds of tool calls a subagent may use before it is forced to answer.
522 const SUBAGENT_MAX_TOOL_ROUNDS: usize = 8;
523 /// Cap on the answer text returned to the caller.
524 const SUBAGENT_RESULT_CHAR_LIMIT: usize = 8_000;
525
526 /// Returned when `task` is called but no subagent backend can be built.
527 const SUBAGENT_UNAVAILABLE: &str = "The task tool is not available on-device \
528 yet: on-device inference has a single conversation context. Do the research \
529 yourself with the read-only tools.";
530
531 /// Builds a fresh backend for one subagent run, or `None` when the active
532 /// inference cannot host a second conversation (on-device).
533 pub type SubagentFactory = Box<dyn Fn() -> Option<Arc<dyn InferenceBackend>> + Send + Sync>;
534
535 static SUBAGENT_FACTORY: OnceLock<SubagentFactory> = OnceLock::new();
536
537 /// Register the process-wide subagent factory. Called once at startup by the
538 /// surface that resolved the inference provider; later calls are ignored.
539 pub fn set_subagent_factory(factory: SubagentFactory) {
540 let _ = SUBAGENT_FACTORY.set(factory);
541 }
542
543 /// Whether a `task` call could run right now: a factory is registered and it
544 /// can build a backend. The spec builders (`agent_tools_as_specs` /
545 /// `build_tool_specs`) use this to offer the tool only when it works, the same
546 /// conditional pattern as the `skill` tool.
547 pub fn subagent_available() -> bool {
548 SUBAGENT_FACTORY
549 .get()
550 .is_some_and(|factory| factory().is_some())
551 }
552
553 /// The read-only toolset offered to a subagent, filtered from [`all_tools`] by
554 /// name. Never contains `task` (recursion) or any mutating tool.
555 pub fn subagent_tool_specs() -> Vec<ToolSpec> {
556 all_tools()
557 .into_iter()
558 .filter(|tool| SUBAGENT_TOOL_NAMES.contains(&tool.name))
559 .map(|tool| ToolSpec {
560 name: tool.name.to_string(),
561 description: tool.description.to_string(),
562 parameters_schema: tool.parameters_schema.to_string(),
563 })
564 .collect()
565 }
566
567 /// Spec for the `task` tool. Lives in the `*_as_specs`/`build_tool_specs`
568 /// layer (like `skill` and MCP tools), not in [`all_tools`], because it is
569 /// only offered when [`subagent_available`] is true.
570 pub fn task_tool_spec() -> ToolSpec {
571 ToolSpec {
572 name: TASK_TOOL_NAME.to_string(),
573 description: "Delegate a self-contained research task to a subagent that \
574 runs in a fresh conversation and returns only its final answer. The \
575 subagent can read files, list directories, search, glob, and read \
576 websites, but cannot modify anything. Use this for exploratory \
577 questions whose intermediate file reads would otherwise clutter this \
578 conversation (e.g. \"where is X implemented and how does it work?\"). \
579 The subagent cannot see this conversation, so the prompt must be \
580 fully self-contained: include absolute paths, symbol names, and \
581 exactly what the answer should contain."
582 .to_string(),
583 parameters_schema: json!({
584 "type": "object",
585 "properties": {
586 "description": {
587 "type": "string",
588 "description": "A short (3-5 word) summary of the task, for progress display."
589 },
590 "prompt": {
591 "type": "string",
592 "description": "The full task for the subagent. Must be self-contained: the subagent sees nothing of this conversation."
593 }
594 },
595 "required": ["description", "prompt"],
596 "additionalProperties": false
597 })
598 .to_string(),
599 }
600 }
601
602 /// The `task` tool entry point used by [`execute_tool`].
603 async fn exec_task(arguments: &str) -> String {
604 exec_task_with(arguments, SUBAGENT_FACTORY.get()).await
605 }
606
607 /// Core of the `task` tool, parameterized on the factory so tests can exercise
608 /// the unavailable path without touching the process-global `OnceLock`.
609 async fn exec_task_with(arguments: &str, factory: Option<&SubagentFactory>) -> String {
610 let args: Value = match serde_json::from_str(arguments) {
611 Ok(v) => v,
612 Err(err) => return format!("Error: failed to parse arguments: {err}"),
613 };
614
615 let prompt = match args.get("prompt").and_then(Value::as_str) {
616 Some(p) if !p.trim().is_empty() => p,
617 _ => return "Error: missing required parameter \"prompt\"".to_string(),
618 };
619 let description = args
620 .get("description")
621 .and_then(Value::as_str)
622 .unwrap_or("(no description)");
623
624 let Some(backend) = factory.and_then(|build| build()) else {
625 return SUBAGENT_UNAVAILABLE.to_string();
626 };
627
628 log::info!("task: running subagent — {description}");
629 run_subagent(backend.as_ref(), prompt).await
630 }
631
632 /// The nested agent loop: a fresh conversation, read-only tools, a round cap,
633 /// and only the final text returned. Mirrors the main loops in `main.rs` /
634 /// `chat.rs`: offer tools each round, and pass `tools = None` on the last
635 /// round to force a text answer.
636 async fn run_subagent(backend: &dyn InferenceBackend, prompt: &str) -> String {
637 let specs = subagent_tool_specs();
638
639 let mut result = match backend.send_message_with_tools(prompt, &specs, None).await {
640 Ok(r) => r,
641 Err(err) => return format!("Error: subagent inference failed: {err}"),
642 };
643
644 let mut round = 0;
645 while !result.tool_calls.is_empty() && round < SUBAGENT_MAX_TOOL_ROUNDS {
646 round += 1;
647 log::info!(
648 "task: subagent tool round {round} — {} call(s)",
649 result.tool_calls.len()
650 );
651
652 let mut tool_results = Vec::with_capacity(result.tool_calls.len());
653 for call in &result.tool_calls {
654 // Hard gate, not just advertisement: even if the model asks for a
655 // tool outside the offered set, only read-only tools execute here.
656 let content = if SUBAGENT_TOOL_NAMES.contains(&call.name.as_str()) {
657 // Boxed to break the async cycle: execute_tool → task →
658 // run_subagent → execute_tool.
659 Box::pin(execute_tool(&call.name, &call.arguments)).await
660 } else {
661 format!(
662 "Error: `{}` is not available to a subagent. Only these \
663 read-only tools are: {}.",
664 call.name,
665 SUBAGENT_TOOL_NAMES.join(", ")
666 )
667 };
668 tool_results.push(ToolResult {
669 tool_call_id: call.id.clone(),
670 content,
671 });
672 }
673
674 // On the last round, offer no tools so the model must produce text.
675 let next_tools = if round < SUBAGENT_MAX_TOOL_ROUNDS {
676 Some(specs.as_slice())
677 } else {
678 None
679 };
680 result = match backend
681 .send_tool_results(tool_results, next_tools, None)
682 .await
683 {
684 Ok(r) => r,
685 Err(err) => return format!("Error: subagent inference failed: {err}"),
686 };
687 }
688
689 let text = result.text.trim();
690 if text.is_empty() {
691 return "The subagent finished without a text answer.".to_string();
692 }
693
694 let total = text.chars().count();
695 if total > SUBAGENT_RESULT_CHAR_LIMIT {
696 let truncated: String = text.chars().take(SUBAGENT_RESULT_CHAR_LIMIT).collect();
697 return format!(
698 "{truncated}\n\n--- truncated (showing {SUBAGENT_RESULT_CHAR_LIMIT} of {total} \
699 characters of the subagent's answer) ---"
700 );
701 }
702 text.to_string()
703 }
704
705 fn absolute_path(path: &Path) -> PathBuf {
706 if path.is_absolute() {
707 path.to_path_buf()
708 } else {
709 std::env::current_dir()
710 .unwrap_or_else(|_| PathBuf::from("."))
711 .join(path)
712 }
713 }
714
715 fn absolute_path_string(path: &Path) -> String {
716 absolute_path(path).display().to_string()
717 }
718
719 // ── read_file ────────────────────────────────────────────────────────────────
720
721 fn exec_read_file(arguments: &str) -> String {
722 let args: Value = match serde_json::from_str(arguments) {
723 Ok(v) => v,
724 Err(err) => return format!("Error: failed to parse arguments: {err}"),
725 };
726
727 let path_str = match args.get("path").and_then(Value::as_str) {
728 Some(p) => p,
729 None => return "Error: missing required parameter \"path\"".to_string(),
730 };
731
732 let start_line = args
733 .get("start_line")
734 .and_then(Value::as_u64)
735 .map(|n| n as usize);
736 let end_line = args
737 .get("end_line")
738 .and_then(Value::as_u64)
739 .map(|n| n as usize);
740
741 let path = Path::new(path_str);
742 let absolute_path = absolute_path(path);
743 let absolute_path_str = absolute_path.display().to_string();
744
745 if !absolute_path.exists() {
746 return format!("Error: path does not exist: {absolute_path_str}");
747 }
748
749 if !absolute_path.is_file() {
750 return format!("Error: path is not a file: {absolute_path_str}");
751 }
752
753 match fs::read_to_string(&absolute_path) {
754 Ok(contents) => {
755 if start_line.is_some() || end_line.is_some() {
756 let lines: Vec<&str> = contents.lines().collect();
757 let total = lines.len();
758 let start = start_line.unwrap_or(1).max(1);
759 let end = end_line.unwrap_or(total).min(total);
760
761 if start > total {
762 return format!(
763 "Error: start_line {start} is beyond end of file ({total} lines)"
764 );
765 }
766
767 let selected: Vec<&str> = lines[(start - 1)..end].to_vec();
768 let range_text = selected.join("\n");
769 format!("Lines {start}-{end} of {total} in {absolute_path_str}:\n{range_text}")
770 } else if contents.len() > READ_FILE_CHAR_LIMIT {
771 let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect();
772 format!(
773 "{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---",
774 contents.len()
775 )
776 } else {
777 contents
778 }
779 }
780 Err(err) => format!("Error: could not read file: {err}"),
781 }
782 }
783
784 // ── list_directory ───────────────────────────────────────────────────────────
785
786 fn exec_list_directory(arguments: &str) -> String {
787 let args: Value = match serde_json::from_str(arguments) {
788 Ok(v) => v,
789 Err(err) => return format!("Error: failed to parse arguments: {err}"),
790 };
791
792 let path_str = match args.get("path").and_then(Value::as_str) {
793 Some(p) => p,
794 None => return "Error: missing required parameter \"path\"".to_string(),
795 };
796
797 let path = Path::new(path_str);
798 let absolute_path = absolute_path(path);
799 let absolute_path_str = absolute_path.display().to_string();
800
801 if !absolute_path.exists() {
802 return format!("Error: path does not exist: {absolute_path_str}");
803 }
804
805 if !absolute_path.is_dir() {
806 return format!("Error: path is not a directory: {absolute_path_str}");
807 }
808
809 let entries = match fs::read_dir(&absolute_path) {
810 Ok(rd) => rd,
811 Err(err) => return format!("Error: could not read directory: {err}"),
812 };
813
814 let mut dirs: Vec<String> = Vec::new();
815 let mut files: Vec<String> = Vec::new();
816
817 for entry in entries {
818 let entry = match entry {
819 Ok(e) => e,
820 Err(err) => {
821 files.push(format!("[ERR] {err}"));
822 continue;
823 }
824 };
825
826 let name = entry.file_name().to_string_lossy().to_string();
827
828 let is_dir = match entry.file_type() {
829 Ok(ft) => ft.is_dir(),
830 Err(_) => false,
831 };
832
833 if is_dir {
834 dirs.push(format!("[DIR] {name}"));
835 } else {
836 files.push(format!("[FILE] {name}"));
837 }
838 }
839
840 dirs.sort();
841 files.sort();
842
843 dirs.extend(files);
844
845 if dirs.is_empty() {
846 return format!("(empty directory: {absolute_path_str})");
847 }
848
849 dirs.join("\n")
850 }
851
852 // ── search_files ─────────────────────────────────────────────────────────────
853
854 fn exec_search_files(arguments: &str) -> String {
855 let args: Value = match serde_json::from_str(arguments) {
856 Ok(v) => v,
857 Err(err) => return format!("Error: failed to parse arguments: {err}"),
858 };
859
860 let pattern_str = match args.get("pattern").and_then(Value::as_str) {
861 Some(p) => p,
862 None => return "Error: missing required parameter \"pattern\"".to_string(),
863 };
864
865 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
866
867 let re = match Regex::new(pattern_str) {
868 Ok(r) => r,
869 Err(err) => return format!("Error: invalid regex pattern: {err}"),
870 };
871
872 // Optional file-name filter compiled from a glob (e.g. "*.rs").
873 let name_filter = match args.get("file_glob").and_then(Value::as_str) {
874 Some(glob) => match Regex::new(&glob_to_regex(glob)) {
875 Ok(r) => Some(r),
876 Err(err) => return format!("Error: invalid file_glob: {err}"),
877 },
878 None => None,
879 };
880
881 let limit = args
882 .get("max_results")
883 .and_then(Value::as_u64)
884 .map(|n| (n as usize).clamp(1, SEARCH_RESULTS_HARD_CAP))
885 .unwrap_or(SEARCH_FILES_MATCH_LIMIT);
886
887 let root = Path::new(root_str);
888 let absolute_root = absolute_path(root);
889 let absolute_root_str = absolute_root.display().to_string();
890
891 if !absolute_root.exists() {
892 return format!("Error: path does not exist: {absolute_root_str}");
893 }
894
895 if !absolute_root.is_dir() {
896 return format!("Error: path is not a directory: {absolute_root_str}");
897 }
898
899 let mut matches: Vec<String> = Vec::new();
900 walk_and_search(
901 &absolute_root,
902 &re,
903 name_filter.as_ref(),
904 limit,
905 &mut matches,
906 );
907
908 if matches.is_empty() {
909 return format!("No matches found for pattern: {pattern_str}");
910 }
911
912 let total = matches.len();
913 if total > limit {
914 matches.truncate(limit);
915 matches.push(format!(
916 "\n--- truncated (showing {limit} of {total}+ matches; raise max_results to see more) ---"
917 ));
918 }
919
920 matches.join("\n")
921 }
922
923 /// Collects up to `limit + 1` matches (the extra signals truncation) so a broad
924 /// pattern can't walk an entire tree once enough hits are found.
925 fn walk_and_search(
926 dir: &Path,
927 re: &Regex,
928 name_filter: Option<&Regex>,
929 limit: usize,
930 matches: &mut Vec<String>,
931 ) {
932 let entries = match fs::read_dir(dir) {
933 Ok(rd) => rd,
934 Err(_) => return,
935 };
936
937 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
938 sorted.sort_by_key(|e| e.file_name());
939
940 for entry in sorted {
941 if matches.len() > limit {
942 return;
943 }
944
945 let path = entry.path();
946 let name = entry.file_name();
947 let name_str = name.to_string_lossy();
948
949 if name_str.starts_with('.') {
950 continue;
951 }
952
953 if path.is_dir() {
954 walk_and_search(&path, re, name_filter, limit, matches);
955 } else if path.is_file() {
956 if let Some(filter) = name_filter
957 && !filter.is_match(&name_str)
958 {
959 continue;
960 }
961 search_file(&path, re, matches);
962 }
963 }
964 }
965
966 /// skips non-UTF-8 files (probably binary).
967 fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
968 let contents = match fs::read_to_string(path) {
969 Ok(c) => c,
970 Err(_) => return,
971 };
972
973 let display_path = absolute_path_string(path);
974
975 for (line_idx, line) in contents.lines().enumerate() {
976 if re.is_match(line) {
977 let line_number = line_idx + 1;
978 matches.push(format!("{display_path}:{line_number}: {line}"));
979 }
980 }
981 }
982
983 // ── read_website ─────────────────────────────────────────────────────────────
984
985 fn exec_read_website(arguments: &str) -> String {
986 let args: Value = match serde_json::from_str(arguments) {
987 Ok(v) => v,
988 Err(err) => return format!("Error: failed to parse arguments: {err}"),
989 };
990
991 let url = match args.get("url").and_then(Value::as_str) {
992 Some(u) => u,
993 None => return "Error: missing required parameter \"url\"".to_string(),
994 };
995
996 if !(url.starts_with("http://") || url.starts_with("https://")) {
997 return format!("Error: url must start with http:// or https://: {url}");
998 }
999
1000 let client = match reqwest::blocking::Client::builder()
1001 .timeout(WEBSITE_READ_TIMEOUT)
1002 .user_agent(WEBSITE_USER_AGENT)
1003 .build()
1004 {
1005 Ok(client) => client,
1006 Err(err) => return format!("Error: failed to build website client: {err}"),
1007 };
1008
1009 let response = match client.get(url).send() {
1010 Ok(r) => r,
1011 Err(err) => return format!("Error: failed to fetch website: {err}"),
1012 };
1013
1014 let final_url = response.url().to_string();
1015 let status = response.status();
1016 if !status.is_success() {
1017 return format!("Error: website returned HTTP {status} for {final_url}");
1018 }
1019
1020 let body = match response.text() {
1021 Ok(text) => text,
1022 Err(err) => return format!("Error: failed to read website body: {err}"),
1023 };
1024
1025 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
1026 .unwrap()
1027 .captures(&body)
1028 .and_then(|captures| captures.get(1))
1029 .map(|m| {
1030 Regex::new(r"\s+")
1031 .unwrap()
1032 .replace_all(m.as_str(), " ")
1033 .trim()
1034 .to_string()
1035 })
1036 .filter(|title| !title.is_empty());
1037
1038 let with_block_breaks = Regex::new(
1039 r"(?is)</?(?:p|div|section|article|main|aside|header|footer|nav|li|ul|ol|h1|h2|h3|h4|h5|h6|br|tr|td|th)[^>]*>",
1040 )
1041 .unwrap()
1042 .replace_all(&body, "\n");
1043 let without_scripts = Regex::new(r"(?is)<script[^>]*>.*?</script>")
1044 .unwrap()
1045 .replace_all(&with_block_breaks, " ");
1046 let without_styles = Regex::new(r"(?is)<style[^>]*>.*?</style>")
1047 .unwrap()
1048 .replace_all(&without_scripts, " ");
1049 let without_tags = Regex::new(r"(?is)<[^>]+>")
1050 .unwrap()
1051 .replace_all(&without_styles, " ");
1052 let normalized_newlines = without_tags
1053 .replace("&nbsp;", " ")
1054 .replace("&amp;", "&")
1055 .replace("&lt;", "<")
1056 .replace("&gt;", ">")
1057 .replace("&quot;", "\"")
1058 .replace("&#39;", "'");
1059 let collapsed_lines = Regex::new(r"[ \t]+")
1060 .unwrap()
1061 .replace_all(&normalized_newlines, " ");
1062 let collapsed_breaks = Regex::new(r"\n\s*\n+")
1063 .unwrap()
1064 .replace_all(&collapsed_lines, "\n\n");
1065 let cleaned = collapsed_breaks
1066 .lines()
1067 .map(str::trim)
1068 .filter(|line| !line.is_empty())
1069 .collect::<Vec<_>>()
1070 .join("\n");
1071
1072 if cleaned.is_empty() {
1073 return format!("Fetched {url}, but no readable text content was found.");
1074 }
1075
1076 let mut metadata = vec![format!("URL: {final_url}")];
1077 if let Some(title) = &title {
1078 metadata.push(format!("Title: {title}"));
1079 }
1080
1081 let body_text = match title {
1082 Some(title) if !cleaned.starts_with(&title) => cleaned,
1083 _ => cleaned,
1084 };
1085
1086 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
1087
1088 if output.len() > WEBSITE_READ_CHAR_LIMIT {
1089 let truncated: String = output.chars().take(WEBSITE_READ_CHAR_LIMIT).collect();
1090 return format!(
1091 "{truncated}\n\n--- truncated (showing {WEBSITE_READ_CHAR_LIMIT} of {} characters) ---",
1092 output.len()
1093 );
1094 }
1095
1096 output
1097 }
1098
1099 // ── create_directory ─────────────────────────────────────────────────────────
1100
1101 fn exec_create_directory(arguments: &str) -> String {
1102 let args: Value = match serde_json::from_str(arguments) {
1103 Ok(v) => v,
1104 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1105 };
1106
1107 let path_str = match args.get("path").and_then(Value::as_str) {
1108 Some(p) => p,
1109 None => return "Error: missing required parameter \"path\"".to_string(),
1110 };
1111
1112 let path = Path::new(path_str);
1113 let absolute_path = absolute_path(path);
1114 let absolute_path_str = absolute_path.display().to_string();
1115
1116 if absolute_path.exists() {
1117 if absolute_path.is_dir() {
1118 return format!("Directory already exists: {absolute_path_str}");
1119 }
1120 return format!("Error: path exists and is not a directory: {absolute_path_str}");
1121 }
1122
1123 match fs::create_dir_all(&absolute_path) {
1124 Ok(()) => format!("Created directory: {absolute_path_str}"),
1125 Err(err) => format!("Error: could not create directory: {err}"),
1126 }
1127 }
1128
1129 /// fails if file exists so the LLM is forced to use `edit_file` for modifications.
1130 fn exec_create_file(arguments: &str) -> String {
1131 let args: Value = match serde_json::from_str(arguments) {
1132 Ok(v) => v,
1133 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1134 };
1135
1136 let path_str = match args.get("path").and_then(Value::as_str) {
1137 Some(p) => p,
1138 None => return "Error: missing required parameter \"path\"".to_string(),
1139 };
1140
1141 let content = match args.get("content").and_then(Value::as_str) {
1142 Some(c) => c,
1143 None => return "Error: missing required parameter \"content\"".to_string(),
1144 };
1145
1146 let path = Path::new(path_str);
1147 let absolute_path = absolute_path(path);
1148 let absolute_path_str = absolute_path.display().to_string();
1149
1150 if absolute_path.exists() {
1151 return format!(
1152 "Error: file already exists: {absolute_path_str} — use edit_file to modify existing files"
1153 );
1154 }
1155
1156 if let Some(parent) = absolute_path.parent()
1157 && !parent.as_os_str().is_empty()
1158 && !parent.exists()
1159 && let Err(err) = fs::create_dir_all(parent)
1160 {
1161 return format!("Error: could not create parent directories: {err}");
1162 }
1163
1164 match fs::write(&absolute_path, content) {
1165 Ok(()) => format!(
1166 "Created file: {absolute_path_str} ({} bytes)",
1167 content.len()
1168 ),
1169 Err(err) => format!("Error: could not write file: {err}"),
1170 }
1171 }
1172
1173 // ── edit_file / multi_edit ─────────────────────────────────────────────────
1174
1175 /// Apply one exact-substring replacement to `contents`. Returns the updated
1176 /// string, or a human-readable explanation of why the match failed so the model
1177 /// can correct itself in a single follow-up instead of guessing blindly.
1178 fn apply_edit(
1179 contents: &str,
1180 old_text: &str,
1181 new_text: &str,
1182 replace_all: bool,
1183 ) -> Result<String, String> {
1184 if old_text.is_empty() {
1185 return Err("old_text is empty; nothing to match".to_string());
1186 }
1187 if old_text == new_text {
1188 return Err("old_text and new_text are identical; no change to make".to_string());
1189 }
1190
1191 let occurrences = contents.matches(old_text).count();
1192
1193 if occurrences == 0 {
1194 return Err(format!(
1195 "old_text not found. Use read_file to copy the exact text \
1196 (including whitespace and indentation) to replace.{}",
1197 nearest_line_hint(contents, old_text)
1198 ));
1199 }
1200
1201 if occurrences > 1 && !replace_all {
1202 return Err(format!(
1203 "old_text appears {occurrences} times; include more surrounding context so it \
1204 matches exactly once, or set replace_all to true to change every occurrence."
1205 ));
1206 }
1207
1208 if replace_all {
1209 Ok(contents.replace(old_text, new_text))
1210 } else {
1211 Ok(contents.replacen(old_text, new_text, 1))
1212 }
1213 }
1214
1215 /// When `old_text` doesn't match verbatim, point at the line whose trimmed text
1216 /// equals the first trimmed line of `old_text` — the usual culprit is a
1217 /// whitespace/indentation mismatch, and naming the line lets the model fix it.
1218 fn nearest_line_hint(contents: &str, old_text: &str) -> String {
1219 let first = old_text.lines().find(|l| !l.trim().is_empty());
1220 let Some(first) = first.map(str::trim) else {
1221 return String::new();
1222 };
1223 for (idx, line) in contents.lines().enumerate() {
1224 if line.trim() == first {
1225 return format!(
1226 " (the first line of old_text appears at line {}, so the difference is likely \
1227 whitespace or indentation)",
1228 idx + 1
1229 );
1230 }
1231 }
1232 String::new()
1233 }
1234
1235 fn exec_edit_file(arguments: &str) -> String {
1236 let args: Value = match serde_json::from_str(arguments) {
1237 Ok(v) => v,
1238 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1239 };
1240
1241 let path_str = match args.get("path").and_then(Value::as_str) {
1242 Some(p) => p,
1243 None => return "Error: missing required parameter \"path\"".to_string(),
1244 };
1245
1246 let old_text = match args.get("old_text").and_then(Value::as_str) {
1247 Some(t) => t,
1248 None => return "Error: missing required parameter \"old_text\"".to_string(),
1249 };
1250
1251 let new_text = match args.get("new_text").and_then(Value::as_str) {
1252 Some(t) => t,
1253 None => return "Error: missing required parameter \"new_text\"".to_string(),
1254 };
1255
1256 let replace_all = args
1257 .get("replace_all")
1258 .and_then(Value::as_bool)
1259 .unwrap_or(false);
1260
1261 let path = Path::new(path_str);
1262 let absolute_path = absolute_path(path);
1263 let absolute_path_str = absolute_path.display().to_string();
1264
1265 if !absolute_path.exists() {
1266 return format!(
1267 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1268 );
1269 }
1270
1271 if !absolute_path.is_file() {
1272 return format!("Error: path is not a file: {absolute_path_str}");
1273 }
1274
1275 let contents = match fs::read_to_string(&absolute_path) {
1276 Ok(c) => c,
1277 Err(err) => return format!("Error: could not read file: {err}"),
1278 };
1279
1280 let updated = match apply_edit(&contents, old_text, new_text, replace_all) {
1281 Ok(updated) => updated,
1282 Err(why) => return format!("Error: {why} (in {absolute_path_str})"),
1283 };
1284
1285 match fs::write(&absolute_path, &updated) {
1286 Ok(()) => format!(
1287 "Edited file: {absolute_path_str} ({} bytes written)",
1288 updated.len()
1289 ),
1290 Err(err) => format!("Error: could not write file: {err}"),
1291 }
1292 }
1293
1294 /// Apply a batch of edits to one file atomically: each edit is applied to the
1295 /// result of the previous one, and the file is only written if *every* edit
1296 /// matches. A failure leaves the file untouched.
1297 fn exec_multi_edit(arguments: &str) -> String {
1298 let args: Value = match serde_json::from_str(arguments) {
1299 Ok(v) => v,
1300 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1301 };
1302
1303 let path_str = match args.get("path").and_then(Value::as_str) {
1304 Some(p) => p,
1305 None => return "Error: missing required parameter \"path\"".to_string(),
1306 };
1307
1308 let edits = match args.get("edits").and_then(Value::as_array) {
1309 Some(e) if !e.is_empty() => e,
1310 Some(_) => return "Error: \"edits\" must contain at least one edit".to_string(),
1311 None => return "Error: missing required parameter \"edits\"".to_string(),
1312 };
1313
1314 let path = Path::new(path_str);
1315 let absolute_path = absolute_path(path);
1316 let absolute_path_str = absolute_path.display().to_string();
1317
1318 if !absolute_path.exists() {
1319 return format!(
1320 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1321 );
1322 }
1323
1324 if !absolute_path.is_file() {
1325 return format!("Error: path is not a file: {absolute_path_str}");
1326 }
1327
1328 let mut working = match fs::read_to_string(&absolute_path) {
1329 Ok(c) => c,
1330 Err(err) => return format!("Error: could not read file: {err}"),
1331 };
1332
1333 for (idx, edit) in edits.iter().enumerate() {
1334 let old_text = match edit.get("old_text").and_then(Value::as_str) {
1335 Some(t) => t,
1336 None => return format!("Error: edit #{} is missing \"old_text\"", idx + 1),
1337 };
1338 let new_text = match edit.get("new_text").and_then(Value::as_str) {
1339 Some(t) => t,
1340 None => return format!("Error: edit #{} is missing \"new_text\"", idx + 1),
1341 };
1342 let replace_all = edit
1343 .get("replace_all")
1344 .and_then(Value::as_bool)
1345 .unwrap_or(false);
1346
1347 match apply_edit(&working, old_text, new_text, replace_all) {
1348 Ok(updated) => working = updated,
1349 Err(why) => {
1350 return format!(
1351 "Error: edit #{} failed: {why}. No changes were written to {absolute_path_str}.",
1352 idx + 1
1353 );
1354 }
1355 }
1356 }
1357
1358 match fs::write(&absolute_path, &working) {
1359 Ok(()) => format!(
1360 "Applied {} edits to {absolute_path_str} ({} bytes written)",
1361 edits.len(),
1362 working.len()
1363 ),
1364 Err(err) => format!("Error: could not write file: {err}"),
1365 }
1366 }
1367
1368 // ── glob ─────────────────────────────────────────────────────────────────────
1369
1370 /// Translate a shell-style glob into an anchored regex. Supports `*`
1371 /// (non-separator run), `**` (any number of directories), `?` (one
1372 /// non-separator), and `{a,b}` alternation; everything else is matched
1373 /// literally. Used by the `glob` tool (against relative paths), by
1374 /// `search_files`' `file_glob` filter (against bare file names), and by
1375 /// `crate::permissions` rule patterns (which re-anchor the result).
1376 pub(crate) fn glob_to_regex(glob: &str) -> String {
1377 let chars: Vec<char> = glob.chars().collect();
1378 let mut re = String::from("^");
1379 let mut brace_depth = 0usize;
1380 let mut i = 0;
1381
1382 while i < chars.len() {
1383 let c = chars[i];
1384 match c {
1385 '*' => {
1386 if i + 1 < chars.len() && chars[i + 1] == '*' {
1387 i += 1; // consume the second '*'
1388 if i + 1 < chars.len() && chars[i + 1] == '/' {
1389 // `**/` matches zero or more leading directories.
1390 re.push_str("(?:.*/)?");
1391 i += 1; // consume the '/'
1392 } else {
1393 re.push_str(".*");
1394 }
1395 } else {
1396 re.push_str("[^/]*");
1397 }
1398 }
1399 '?' => re.push_str("[^/]"),
1400 '{' => {
1401 brace_depth += 1;
1402 re.push_str("(?:");
1403 }
1404 '}' if brace_depth > 0 => {
1405 brace_depth -= 1;
1406 re.push(')');
1407 }
1408 ',' if brace_depth > 0 => re.push('|'),
1409 // Escape regex metacharacters so they match literally. (`{` is always
1410 // consumed by the brace arm above; an unmatched `}` lands here.)
1411 '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => {
1412 re.push('\\');
1413 re.push(c);
1414 }
1415 other => re.push(other),
1416 }
1417 i += 1;
1418 }
1419
1420 re.push('$');
1421 re
1422 }
1423
1424 fn exec_glob(arguments: &str) -> String {
1425 let args: Value = match serde_json::from_str(arguments) {
1426 Ok(v) => v,
1427 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1428 };
1429
1430 let pattern = match args.get("pattern").and_then(Value::as_str) {
1431 Some(p) => p,
1432 None => return "Error: missing required parameter \"pattern\"".to_string(),
1433 };
1434
1435 let re = match Regex::new(&glob_to_regex(pattern)) {
1436 Ok(r) => r,
1437 Err(err) => return format!("Error: invalid glob pattern: {err}"),
1438 };
1439
1440 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1441 let absolute_root = absolute_path(Path::new(root_str));
1442 let absolute_root_str = absolute_root.display().to_string();
1443
1444 if !absolute_root.exists() {
1445 return format!("Error: path does not exist: {absolute_root_str}");
1446 }
1447 if !absolute_root.is_dir() {
1448 return format!("Error: path is not a directory: {absolute_root_str}");
1449 }
1450
1451 let mut found: Vec<(std::time::SystemTime, String)> = Vec::new();
1452 glob_walk(&absolute_root, &absolute_root, &re, &mut found);
1453
1454 if found.is_empty() {
1455 return format!("No files match glob: {pattern}");
1456 }
1457
1458 // Most-recently-modified first.
1459 found.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
1460
1461 let total = found.len();
1462 let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect();
1463 if total > SEARCH_RESULTS_HARD_CAP {
1464 paths.truncate(SEARCH_RESULTS_HARD_CAP);
1465 paths.push(format!(
1466 "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---"
1467 ));
1468 }
1469
1470 paths.join("\n")
1471 }
1472
1473 fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) {
1474 if out.len() > SEARCH_RESULTS_HARD_CAP {
1475 return;
1476 }
1477
1478 let entries = match fs::read_dir(dir) {
1479 Ok(rd) => rd,
1480 Err(_) => return,
1481 };
1482
1483 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1484 sorted.sort_by_key(|e| e.file_name());
1485
1486 for entry in sorted {
1487 if out.len() > SEARCH_RESULTS_HARD_CAP {
1488 return;
1489 }
1490
1491 let path = entry.path();
1492 let name = entry.file_name();
1493 if name.to_string_lossy().starts_with('.') {
1494 continue;
1495 }
1496
1497 if path.is_dir() {
1498 glob_walk(root, &path, re, out);
1499 } else if path.is_file() {
1500 let relative = path
1501 .strip_prefix(root)
1502 .unwrap_or(&path)
1503 .to_string_lossy()
1504 .replace('\\', "/");
1505 if re.is_match(&relative) {
1506 let mtime = entry
1507 .metadata()
1508 .and_then(|m| m.modified())
1509 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1510 out.push((mtime, absolute_path_string(&path)));
1511 }
1512 }
1513 }
1514 }
1515
1516 // ── write_todos ──────────────────────────────────────────────────────────────
1517
1518 /// Renders the model's task checklist back as the tool result so the surface
1519 /// (TUI / ACP client) can show live progress. Pure presentation — the list is
1520 /// owned by the model, not persisted here.
1521 fn exec_write_todos(arguments: &str) -> String {
1522 let args: Value = match serde_json::from_str(arguments) {
1523 Ok(v) => v,
1524 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1525 };
1526
1527 let todos = match args.get("todos").and_then(Value::as_array) {
1528 Some(t) if !t.is_empty() => t,
1529 Some(_) => return "Error: \"todos\" must contain at least one item".to_string(),
1530 None => return "Error: missing required parameter \"todos\"".to_string(),
1531 };
1532
1533 let mut lines = Vec::with_capacity(todos.len());
1534 let mut completed = 0usize;
1535
1536 for (idx, todo) in todos.iter().enumerate() {
1537 let content = match todo.get("content").and_then(Value::as_str) {
1538 Some(c) => c.trim(),
1539 None => return format!("Error: todo #{} is missing \"content\"", idx + 1),
1540 };
1541 let status = todo
1542 .get("status")
1543 .and_then(Value::as_str)
1544 .unwrap_or("pending");
1545
1546 let marker = match status {
1547 "completed" => {
1548 completed += 1;
1549 "[x]"
1550 }
1551 "in_progress" => "[~]",
1552 _ => "[ ]",
1553 };
1554 lines.push(format!("{marker} {content}"));
1555 }
1556
1557 format!(
1558 "Task list updated ({completed}/{} done):\n{}",
1559 todos.len(),
1560 lines.join("\n")
1561 )
1562 }
1563
1564 // ── remember ─────────────────────────────────────────────────────────────────
1565
1566 /// Appends a durable note to the project's instruction file so it persists
1567 /// across sessions (the always-on counterpart to a one-off chat message).
1568 fn exec_remember(arguments: &str) -> String {
1569 let args: Value = match serde_json::from_str(arguments) {
1570 Ok(v) => v,
1571 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1572 };
1573
1574 let note = match args.get("note").and_then(Value::as_str) {
1575 Some(n) if !n.trim().is_empty() => n.trim(),
1576 Some(_) => return "Error: \"note\" must not be empty".to_string(),
1577 None => return "Error: missing required parameter \"note\"".to_string(),
1578 };
1579
1580 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1581 remember_at(&cwd, note)
1582 }
1583
1584 /// Core of `remember`, parameterized on the working directory so it can be
1585 /// tested without mutating the process-global current directory.
1586 fn remember_at(cwd: &Path, note: &str) -> String {
1587 let target = crate::instructions::memory_file(cwd);
1588 let target_str = target.display().to_string();
1589
1590 let existed = target.exists();
1591 let mut body = if existed {
1592 match fs::read_to_string(&target) {
1593 Ok(c) => c,
1594 Err(err) => return format!("Error: could not read {target_str}: {err}"),
1595 }
1596 } else {
1597 if let Some(parent) = target.parent()
1598 && !parent.as_os_str().is_empty()
1599 && !parent.exists()
1600 && let Err(err) = fs::create_dir_all(parent)
1601 {
1602 return format!("Error: could not create parent directories: {err}");
1603 }
1604 String::new()
1605 };
1606
1607 // Keep remembered notes grouped under one heading so the file stays tidy.
1608 const HEADING: &str = "## Remembered notes";
1609 if !body.contains(HEADING) {
1610 if !body.is_empty() && !body.ends_with('\n') {
1611 body.push('\n');
1612 }
1613 if !body.is_empty() {
1614 body.push('\n');
1615 }
1616 body.push_str(HEADING);
1617 body.push('\n');
1618 }
1619 if !body.ends_with('\n') {
1620 body.push('\n');
1621 }
1622 body.push_str("- ");
1623 body.push_str(note);
1624 body.push('\n');
1625
1626 match fs::write(&target, &body) {
1627 Ok(()) => {
1628 let verb = if existed { "Appended to" } else { "Created" };
1629 format!("{verb} {target_str}: remembered \"{note}\"")
1630 }
1631 Err(err) => format!("Error: could not write {target_str}: {err}"),
1632 }
1633 }
1634
1635 // ── delete_file ──────────────────────────────────────────────────────────────
1636
1637 /// only removes files or *empty* directories — no recursive deletes.
1638 fn exec_delete_file(arguments: &str) -> String {
1639 let args: Value = match serde_json::from_str(arguments) {
1640 Ok(v) => v,
1641 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1642 };
1643
1644 let path_str = match args.get("path").and_then(Value::as_str) {
1645 Some(p) => p,
1646 None => return "Error: missing required parameter \"path\"".to_string(),
1647 };
1648
1649 let path = Path::new(path_str);
1650 let absolute_path = absolute_path(path);
1651 let absolute_path_str = absolute_path.display().to_string();
1652
1653 if !absolute_path.exists() {
1654 return format!("Error: path does not exist: {absolute_path_str}");
1655 }
1656
1657 if absolute_path.is_dir() {
1658 match fs::remove_dir(&absolute_path) {
1659 Ok(()) => format!("Deleted empty directory: {absolute_path_str}"),
1660 Err(err) => format!(
1661 "Error: could not delete directory: {err}. \
1662 Only empty directories can be deleted."
1663 ),
1664 }
1665 } else {
1666 match fs::remove_file(&absolute_path) {
1667 Ok(()) => format!("Deleted file: {absolute_path_str}"),
1668 Err(err) => format!("Error: could not delete file: {err}"),
1669 }
1670 }
1671 }
1672
1673 // ── run_command ──────────────────────────────────────────────────────────────
1674
1675 const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1676 const COMMAND_OUTPUT_LIMIT: usize = 50_000;
1677
1678 /// Spawn `command_str` through the platform shell with piped stdout/stderr.
1679 /// Shared by the foreground and background paths of `run_command`.
1680 fn spawn_shell(command_str: &str, cwd_path: &Path) -> std::io::Result<std::process::Child> {
1681 #[cfg(unix)]
1682 let (shell, flag) = ("sh", "-c");
1683 #[cfg(windows)]
1684 let (shell, flag) = ("cmd", "/C");
1685
1686 Command::new(shell)
1687 .arg(flag)
1688 .arg(command_str)
1689 .current_dir(cwd_path)
1690 .stdout(std::process::Stdio::piped())
1691 .stderr(std::process::Stdio::piped())
1692 .spawn()
1693 }
1694
1695 /// Trailer identifying siGit Code as the co-author of commits it creates.
1696 /// GitHub detects `Co-authored-by:` trailers on the last lines of a commit
1697 /// message (separated from the body by a blank line) and lists the agent
1698 /// alongside the human author; `297239231+sigitc@users.noreply.github.com`
1699 /// is the noreply address for the <https://github.com/sigitc> account
1700 /// ("siGit Code"), so the co-author is rendered with that account's avatar
1701 /// and profile link. The system prompt asks the model to add this itself;
1702 /// [`ensure_commit_co_author`] is the safety net when it forgets.
1703 pub const COMMIT_CO_AUTHOR_TRAILER: &str =
1704 "Co-Authored-By: siGit Code <297239231+sigitc@users.noreply.github.com>";
1705
1706 /// Run `git <args>` in `cwd`, returning trimmed stdout on success.
1707 fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
1708 let output = Command::new("git")
1709 .args(args)
1710 .current_dir(cwd)
1711 .output()
1712 .ok()?;
1713 output
1714 .status
1715 .success()
1716 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
1717 }
1718
1719 fn git_head(cwd: &Path) -> Option<String> {
1720 git_stdout(cwd, &["rev-parse", "HEAD"])
1721 }
1722
1723 /// Deterministic co-author attribution: if the commit at HEAD lacks the
1724 /// siGit Code trailer, amend it in (via `git commit --amend --trailer`, which
1725 /// places it after a blank line — the format GitHub detects). Never rewrites
1726 /// a commit that is already on a remote. Returns a note describing the amend
1727 /// so the model and user can see it happened.
1728 fn ensure_commit_co_author(cwd: &Path) -> Option<String> {
1729 let message = git_stdout(cwd, &["log", "-1", "--format=%B"])?;
1730 if message
1731 .to_lowercase()
1732 .contains("co-authored-by: sigit code")
1733 {
1734 return None;
1735 }
1736 // Amending changes the commit id; a commit that any remote ref already
1737 // contains must be left alone or the branch diverges from its upstream.
1738 match git_stdout(cwd, &["branch", "-r", "--contains", "HEAD"]) {
1739 Some(remotes) if remotes.is_empty() => {}
1740 _ => return None,
1741 }
1742 let amend = Command::new("git")
1743 .args(["commit", "--amend", "--no-edit", "--trailer"])
1744 .arg(COMMIT_CO_AUTHOR_TRAILER)
1745 .current_dir(cwd)
1746 .output()
1747 .ok()?;
1748 if amend.status.success() {
1749 log::info!(
1750 "appended co-author trailer to the new commit in {}",
1751 cwd.display()
1752 );
1753 Some(format!(
1754 "[siGit Code] The new commit was amended to append the co-author trailer \
1755 \"{COMMIT_CO_AUTHOR_TRAILER}\" (its hash changed)."
1756 ))
1757 } else {
1758 log::warn!(
1759 "could not append co-author trailer in {}: {}",
1760 cwd.display(),
1761 String::from_utf8_lossy(&amend.stderr).trim()
1762 );
1763 None
1764 }
1765 }
1766
1767 /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT unless
1768 /// `run_in_background` is set, in which case the child is registered as a
1769 /// background task and polled with `command_output` / stopped with
1770 /// `kill_command`.
1771 fn exec_run_command(arguments: &str) -> String {
1772 let args: Value = match serde_json::from_str(arguments) {
1773 Ok(v) => v,
1774 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1775 };
1776
1777 let command_str = match args.get("command").and_then(Value::as_str) {
1778 Some(c) => c,
1779 None => return "Error: missing required parameter \"command\"".to_string(),
1780 };
1781
1782 let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1783 let cwd = args
1784 .get("cwd")
1785 .and_then(Value::as_str)
1786 .unwrap_or(&default_cwd);
1787 let cwd_path = absolute_path(Path::new(cwd));
1788 let cwd_str = cwd_path.display().to_string();
1789
1790 if !cwd_path.exists() {
1791 return format!("Error: working directory does not exist: {cwd_str}");
1792 }
1793
1794 let run_in_background = args
1795 .get("run_in_background")
1796 .and_then(Value::as_bool)
1797 .unwrap_or(false);
1798
1799 log::info!("run_command: `{command_str}` in `{cwd_str}` (background: {run_in_background})");
1800
1801 if run_in_background {
1802 return start_background_task(command_str, &cwd_path);
1803 }
1804
1805 // Co-author attribution: note where HEAD is before a command that looks
1806 // like it may commit, so a new commit can be detected afterwards. The
1807 // string check is only a cheap trigger — a false positive costs one
1808 // `git rev-parse` and nothing else. Background tasks skip the gate: their
1809 // commits finish after the tool returns, when there is nothing to amend
1810 // from.
1811 let may_commit = command_str.contains("git") && command_str.contains("commit");
1812 let head_before = if may_commit {
1813 git_head(&cwd_path)
1814 } else {
1815 None
1816 };
1817
1818 let mut child = match spawn_shell(command_str, &cwd_path) {
1819 Ok(c) => c,
1820 Err(err) => return format!("Error: failed to spawn command: {err}"),
1821 };
1822
1823 let start = std::time::Instant::now();
1824 loop {
1825 match child.try_wait() {
1826 Ok(Some(_status)) => break,
1827 Ok(None) => {
1828 if start.elapsed() >= COMMAND_TIMEOUT {
1829 let _ = child.kill();
1830 return format!(
1831 "Error: command timed out after {} seconds and was killed.",
1832 COMMAND_TIMEOUT.as_secs()
1833 );
1834 }
1835 std::thread::sleep(std::time::Duration::from_millis(100));
1836 }
1837 Err(err) => return format!("Error: failed to wait on command: {err}"),
1838 }
1839 }
1840
1841 let output = match child.wait_with_output() {
1842 Ok(o) => o,
1843 Err(err) => return format!("Error: failed to read command output: {err}"),
1844 };
1845
1846 let exit_code = output.status.code().unwrap_or(-1);
1847 let mut combined = String::new();
1848 combined.push_str(&String::from_utf8_lossy(&output.stdout));
1849 combined.push_str(&String::from_utf8_lossy(&output.stderr));
1850
1851 // A new commit appeared under this command: make sure it carries the
1852 // siGit co-author trailer (see `ensure_commit_co_author`).
1853 if may_commit {
1854 let head_after = git_head(&cwd_path);
1855 if head_after.is_some()
1856 && head_after != head_before
1857 && let Some(note) = ensure_commit_co_author(&cwd_path)
1858 {
1859 if !combined.is_empty() && !combined.ends_with('\n') {
1860 combined.push('\n');
1861 }
1862 combined.push_str(&note);
1863 }
1864 }
1865
1866 let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
1867 let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
1868 format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
1869 } else {
1870 combined
1871 };
1872
1873 if output.status.success() {
1874 if truncated.is_empty() {
1875 format!("Command succeeded (exit code {exit_code}) with no output.")
1876 } else {
1877 format!("Exit code {exit_code}:\n{truncated}")
1878 }
1879 } else {
1880 format!("Command failed (exit code {exit_code}):\n{truncated}")
1881 }
1882 }
1883
1884 // ── background tasks (command_output / kill_command) ─────────────────────────
1885
1886 /// A command started with `run_in_background`. The child is never detached,
1887 /// so background tasks die with the sigit process.
1888 struct BackgroundTask {
1889 command: String,
1890 child: std::process::Child,
1891 /// Combined stdout+stderr drained by the reader threads, shared with them.
1892 output: Arc<Mutex<TaskOutput>>,
1893 /// Exit code cached once observed; `-1` when there is no code (killed by
1894 /// a signal). `None` while the task is still running.
1895 exit_code: Option<i32>,
1896 /// Set when the task was stopped via `kill_command`.
1897 killed: bool,
1898 }
1899
1900 /// Output accumulated for a background task since the last poll.
1901 #[derive(Default)]
1902 struct TaskOutput {
1903 buf: String,
1904 /// Whether the oldest output was dropped because `buf` hit the cap.
1905 dropped: bool,
1906 }
1907
1908 /// Process-global background task table (same pattern as `mcp::MCP`).
1909 fn tasks() -> &'static Mutex<HashMap<u64, BackgroundTask>> {
1910 static TASKS: OnceLock<Mutex<HashMap<u64, BackgroundTask>>> = OnceLock::new();
1911 TASKS.get_or_init(|| Mutex::new(HashMap::new()))
1912 }
1913
1914 fn lock_tasks() -> std::sync::MutexGuard<'static, HashMap<u64, BackgroundTask>> {
1915 tasks()
1916 .lock()
1917 .unwrap_or_else(|poisoned| poisoned.into_inner())
1918 }
1919
1920 fn next_task_id() -> u64 {
1921 static NEXT_ID: AtomicU64 = AtomicU64::new(1);
1922 NEXT_ID.fetch_add(1, Ordering::Relaxed)
1923 }
1924
1925 /// Drain a child's stream into the task's shared buffer on a plain std thread,
1926 /// capping the buffer at COMMAND_OUTPUT_LIMIT by dropping the oldest output.
1927 fn spawn_output_reader<R: std::io::Read + Send + 'static>(
1928 mut stream: R,
1929 output: Arc<Mutex<TaskOutput>>,
1930 ) {
1931 std::thread::spawn(move || {
1932 let mut chunk = [0u8; 8192];
1933 loop {
1934 match stream.read(&mut chunk) {
1935 Ok(0) | Err(_) => break,
1936 Ok(n) => {
1937 let text = String::from_utf8_lossy(&chunk[..n]).into_owned();
1938 let mut out = output
1939 .lock()
1940 .unwrap_or_else(|poisoned| poisoned.into_inner());
1941 out.buf.push_str(&text);
1942 if out.buf.len() > COMMAND_OUTPUT_LIMIT {
1943 let mut cut = out.buf.len() - COMMAND_OUTPUT_LIMIT;
1944 while !out.buf.is_char_boundary(cut) {
1945 cut += 1;
1946 }
1947 out.buf.drain(..cut);
1948 out.dropped = true;
1949 }
1950 }
1951 }
1952 }
1953 });
1954 }
1955
1956 /// Background branch of `run_command`: spawn, register, return immediately.
1957 fn start_background_task(command_str: &str, cwd_path: &Path) -> String {
1958 let mut child = match spawn_shell(command_str, cwd_path) {
1959 Ok(c) => c,
1960 Err(err) => return format!("Error: failed to spawn command: {err}"),
1961 };
1962
1963 let output = Arc::new(Mutex::new(TaskOutput::default()));
1964 if let Some(stdout) = child.stdout.take() {
1965 spawn_output_reader(stdout, Arc::clone(&output));
1966 }
1967 if let Some(stderr) = child.stderr.take() {
1968 spawn_output_reader(stderr, Arc::clone(&output));
1969 }
1970
1971 let task_id = next_task_id();
1972 lock_tasks().insert(
1973 task_id,
1974 BackgroundTask {
1975 command: command_str.to_string(),
1976 child,
1977 output,
1978 exit_code: None,
1979 killed: false,
1980 },
1981 );
1982
1983 format!(
1984 "Started background task {task_id}: `{command_str}`. Poll its output and status \
1985 with command_output (task_id: {task_id}); stop it with kill_command. The task is \
1986 killed when sigit exits."
1987 )
1988 }
1989
1990 /// Check (and cache) whether a task's child has exited. Returns the exit code,
1991 /// or `None` while it is still running.
1992 fn poll_exit_code(task: &mut BackgroundTask) -> Option<i32> {
1993 if task.exit_code.is_none()
1994 && let Ok(Some(status)) = task.child.try_wait()
1995 {
1996 task.exit_code = Some(status.code().unwrap_or(-1));
1997 }
1998 task.exit_code
1999 }
2000
2001 /// Take everything the task has printed since the last poll, plus whether
2002 /// older output was dropped at the buffer cap.
2003 fn drain_task_output(task: &BackgroundTask) -> (String, bool) {
2004 let mut out = task
2005 .output
2006 .lock()
2007 .unwrap_or_else(|poisoned| poisoned.into_inner());
2008 (
2009 std::mem::take(&mut out.buf),
2010 std::mem::take(&mut out.dropped),
2011 )
2012 }
2013
2014 fn parse_task_id(arguments: &str) -> Result<u64, String> {
2015 let args: Value = serde_json::from_str(arguments)
2016 .map_err(|err| format!("Error: failed to parse arguments: {err}"))?;
2017 args.get("task_id")
2018 .and_then(Value::as_u64)
2019 .ok_or_else(|| "Error: missing required parameter \"task_id\"".to_string())
2020 }
2021
2022 fn unknown_task(task_id: u64) -> String {
2023 format!(
2024 "Error: no background task with id {task_id}. Start one with run_command and \
2025 run_in_background set to true."
2026 )
2027 }
2028
2029 fn dropped_note(dropped: bool) -> &'static str {
2030 if dropped {
2031 "\n(note: earlier output was dropped after exceeding the 50000-byte buffer)"
2032 } else {
2033 ""
2034 }
2035 }
2036
2037 /// `command_output` tool: output since the last poll + running/exited status.
2038 fn exec_command_output(arguments: &str) -> String {
2039 let task_id = match parse_task_id(arguments) {
2040 Ok(id) => id,
2041 Err(err) => return err,
2042 };
2043
2044 let mut map = lock_tasks();
2045 let Some(task) = map.get_mut(&task_id) else {
2046 return unknown_task(task_id);
2047 };
2048
2049 let was_running = task.exit_code.is_none();
2050 let exit_code = poll_exit_code(task);
2051 if was_running && exit_code.is_some() {
2052 // The child just exited; give the reader threads a moment to flush
2053 // the final output through the pipes before draining the buffer.
2054 std::thread::sleep(std::time::Duration::from_millis(100));
2055 }
2056 let (new_output, dropped) = drain_task_output(task);
2057
2058 let command = &task.command;
2059 let status = match exit_code {
2060 None => format!("Task {task_id} (`{command}`) is still running."),
2061 Some(_) if task.killed => format!("Task {task_id} (`{command}`) was killed."),
2062 Some(code) => format!("Task {task_id} (`{command}`) exited with code {code}."),
2063 };
2064
2065 if new_output.is_empty() {
2066 format!(
2067 "{status} No new output since the last check.{}",
2068 dropped_note(dropped)
2069 )
2070 } else {
2071 format!(
2072 "{status} New output since the last check:{}\n{new_output}",
2073 dropped_note(dropped)
2074 )
2075 }
2076 }
2077
2078 /// `kill_command` tool: stop a background task and report its output tail.
2079 fn exec_kill_command(arguments: &str) -> String {
2080 let task_id = match parse_task_id(arguments) {
2081 Ok(id) => id,
2082 Err(err) => return err,
2083 };
2084
2085 let mut map = lock_tasks();
2086 let Some(task) = map.get_mut(&task_id) else {
2087 return unknown_task(task_id);
2088 };
2089
2090 if let Some(code) = poll_exit_code(task) {
2091 let (new_output, dropped) = drain_task_output(task);
2092 let command = &task.command;
2093 let tail = if new_output.is_empty() {
2094 String::new()
2095 } else {
2096 format!(" Unread output:\n{new_output}")
2097 };
2098 return format!(
2099 "Task {task_id} (`{command}`) had already exited with code {code}; nothing to \
2100 kill.{}{tail}",
2101 dropped_note(dropped)
2102 );
2103 }
2104
2105 if let Err(err) = task.child.kill() {
2106 return format!("Error: failed to kill task {task_id}: {err}");
2107 }
2108 match task.child.wait() {
2109 Ok(status) => task.exit_code = Some(status.code().unwrap_or(-1)),
2110 Err(_) => task.exit_code = Some(-1),
2111 }
2112 task.killed = true;
2113
2114 // Let the reader threads flush whatever was in flight before reporting.
2115 std::thread::sleep(std::time::Duration::from_millis(100));
2116 let (new_output, dropped) = drain_task_output(task);
2117
2118 let command = &task.command;
2119 let tail = if new_output.is_empty() {
2120 " It produced no unread output.".to_string()
2121 } else {
2122 format!(" Last output:\n{new_output}")
2123 };
2124 format!(
2125 "Killed task {task_id} (`{command}`).{}{tail}",
2126 dropped_note(dropped)
2127 )
2128 }
2129
2130 #[cfg(test)]
2131 mod tests {
2132 use super::*;
2133 use std::fs;
2134
2135 #[tokio::test]
2136 async fn test_execute_unknown_tool() {
2137 let result = execute_tool("nonexistent", "{}").await;
2138 assert!(result.starts_with("Unknown tool:"));
2139 }
2140
2141 #[test]
2142 fn test_read_file_missing_path_param() {
2143 let result = exec_read_file("{}");
2144 assert!(result.contains("missing required parameter"));
2145 }
2146
2147 #[test]
2148 fn test_read_file_nonexistent() {
2149 let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#);
2150 assert!(result.contains("does not exist"));
2151 }
2152
2153 #[test]
2154 fn test_read_file_success() {
2155 let dir = std::env::temp_dir().join("sigit_test_read_file");
2156 let _ = fs::create_dir_all(&dir);
2157 let file_path = dir.join("hello.txt");
2158 fs::write(&file_path, "hello world").unwrap();
2159
2160 let args = serde_json::json!({ "path": file_path }).to_string();
2161 let result = exec_read_file(&args);
2162 assert_eq!(result, "hello world");
2163
2164 let _ = fs::remove_dir_all(&dir);
2165 }
2166
2167 #[test]
2168 fn test_list_directory_missing_path_param() {
2169 let result = exec_list_directory("{}");
2170 assert!(result.contains("missing required parameter"));
2171 }
2172
2173 #[test]
2174 fn test_list_directory_success() {
2175 let dir = std::env::temp_dir().join("sigit_test_list_dir");
2176 let _ = fs::remove_dir_all(&dir);
2177 fs::create_dir_all(dir.join("subdir")).unwrap();
2178 fs::write(dir.join("aaa.txt"), "").unwrap();
2179 fs::write(dir.join("bbb.rs"), "").unwrap();
2180
2181 let args = serde_json::json!({ "path": dir }).to_string();
2182 let result = exec_list_directory(&args);
2183
2184 assert!(result.contains("[DIR] subdir"));
2185 assert!(result.contains("[FILE] aaa.txt"));
2186 assert!(result.contains("[FILE] bbb.rs"));
2187
2188 // Directories should appear before files.
2189 let dir_pos = result.find("[DIR]").unwrap();
2190 let file_pos = result.find("[FILE]").unwrap();
2191 assert!(dir_pos < file_pos);
2192
2193 let _ = fs::remove_dir_all(&dir);
2194 }
2195
2196 #[test]
2197 fn test_search_files_invalid_regex() {
2198 let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#);
2199 assert!(result.contains("invalid regex"));
2200 }
2201
2202 #[test]
2203 fn test_search_files_success() {
2204 let dir = std::env::temp_dir().join("sigit_test_search");
2205 let _ = fs::remove_dir_all(&dir);
2206 fs::create_dir_all(&dir).unwrap();
2207 fs::write(
2208 dir.join("code.rs"),
2209 "fn main() {\n println!(\"hello\");\n}\n",
2210 )
2211 .unwrap();
2212 fs::write(dir.join("other.txt"), "no match here\n").unwrap();
2213
2214 let args = serde_json::json!({
2215 "pattern": "println",
2216 "path": dir
2217 })
2218 .to_string();
2219 let result = exec_search_files(&args);
2220
2221 assert!(result.contains("code.rs:2:"));
2222 assert!(result.contains("println"));
2223 assert!(!result.contains("other.txt"));
2224
2225 let _ = fs::remove_dir_all(&dir);
2226 }
2227
2228 #[test]
2229 fn test_search_files_no_matches() {
2230 let dir = std::env::temp_dir().join("sigit_test_search_none");
2231 let _ = fs::remove_dir_all(&dir);
2232 fs::create_dir_all(&dir).unwrap();
2233 fs::write(dir.join("empty.txt"), "nothing special").unwrap();
2234
2235 let args = serde_json::json!({
2236 "pattern": "zzz_will_not_match_42",
2237 "path": dir
2238 })
2239 .to_string();
2240 let result = exec_search_files(&args);
2241 assert!(result.contains("No matches found"));
2242
2243 let _ = fs::remove_dir_all(&dir);
2244 }
2245
2246 #[test]
2247 fn test_all_tools_count() {
2248 let tools = all_tools();
2249 assert_eq!(tools.len(), 15);
2250 assert_eq!(tools[0].name, "read_file");
2251 assert_eq!(tools[1].name, "create_directory");
2252 assert_eq!(tools[2].name, "list_directory");
2253 assert_eq!(tools[3].name, "search_files");
2254 assert_eq!(tools[4].name, "read_website");
2255 assert_eq!(tools[5].name, "create_file");
2256 assert_eq!(tools[6].name, "edit_file");
2257 assert_eq!(tools[7].name, "delete_file");
2258 assert_eq!(tools[8].name, "run_command");
2259 assert_eq!(tools[9].name, "multi_edit");
2260 assert_eq!(tools[10].name, "glob");
2261 assert_eq!(tools[11].name, "write_todos");
2262 assert_eq!(tools[12].name, "remember");
2263 assert_eq!(tools[13].name, "command_output");
2264 assert_eq!(tools[14].name, "kill_command");
2265 }
2266
2267 #[test]
2268 fn test_edit_file_replace_all() {
2269 let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
2270 let _ = fs::remove_dir_all(&dir);
2271 fs::create_dir_all(&dir).unwrap();
2272 let file = dir.join("f.txt");
2273 fs::write(&file, "foo foo foo").unwrap();
2274
2275 // Without replace_all an ambiguous match is rejected.
2276 let args =
2277 serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
2278 let result = exec_edit_file(&args);
2279 assert!(result.contains("appears 3 times"), "{result}");
2280
2281 // With replace_all every occurrence is changed.
2282 let args = serde_json::json!({
2283 "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
2284 })
2285 .to_string();
2286 let result = exec_edit_file(&args);
2287 assert!(result.starts_with("Edited file:"), "{result}");
2288 assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
2289
2290 let _ = fs::remove_dir_all(&dir);
2291 }
2292
2293 #[test]
2294 fn test_edit_file_whitespace_hint() {
2295 let dir = std::env::temp_dir().join("sigit_test_edit_hint");
2296 let _ = fs::remove_dir_all(&dir);
2297 fs::create_dir_all(&dir).unwrap();
2298 let file = dir.join("f.txt");
2299 fs::write(&file, "line one\n indented\nline three\n").unwrap();
2300
2301 // old_text has more indentation than the file, so it isn't a substring,
2302 // but its trimmed content still locates the intended line.
2303 let args = serde_json::json!({
2304 "path": &file, "old_text": " indented", "new_text": "x"
2305 })
2306 .to_string();
2307 let result = exec_edit_file(&args);
2308 assert!(result.contains("line 2"), "{result}");
2309 assert!(result.contains("whitespace"), "{result}");
2310
2311 let _ = fs::remove_dir_all(&dir);
2312 }
2313
2314 #[test]
2315 fn test_multi_edit_atomic_on_failure() {
2316 let dir = std::env::temp_dir().join("sigit_test_multi_edit");
2317 let _ = fs::remove_dir_all(&dir);
2318 fs::create_dir_all(&dir).unwrap();
2319 let file = dir.join("f.txt");
2320 fs::write(&file, "alpha beta gamma").unwrap();
2321
2322 // Second edit can't match -> nothing should be written.
2323 let args = serde_json::json!({
2324 "path": &file,
2325 "edits": [
2326 { "old_text": "alpha", "new_text": "ALPHA" },
2327 { "old_text": "nope", "new_text": "x" }
2328 ]
2329 })
2330 .to_string();
2331 let result = exec_multi_edit(&args);
2332 assert!(result.contains("edit #2 failed"), "{result}");
2333 assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
2334
2335 // All-matching batch applies in sequence.
2336 let args = serde_json::json!({
2337 "path": &file,
2338 "edits": [
2339 { "old_text": "alpha", "new_text": "ALPHA" },
2340 { "old_text": "gamma", "new_text": "GAMMA" }
2341 ]
2342 })
2343 .to_string();
2344 let result = exec_multi_edit(&args);
2345 assert!(result.contains("Applied 2 edits"), "{result}");
2346 assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
2347
2348 let _ = fs::remove_dir_all(&dir);
2349 }
2350
2351 #[test]
2352 fn test_glob_to_regex() {
2353 let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
2354 assert!(re.is_match("src/tools.rs"));
2355 assert!(re.is_match("main.rs")); // `**/` matches zero directories too
2356 assert!(!re.is_match("src/tools.txt"));
2357
2358 let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
2359 assert!(re.is_match("app.ts"));
2360 assert!(re.is_match("app.tsx"));
2361 assert!(!re.is_match("app.js"));
2362 }
2363
2364 #[test]
2365 fn test_glob_tool_success() {
2366 let dir = std::env::temp_dir().join("sigit_test_glob");
2367 let _ = fs::remove_dir_all(&dir);
2368 fs::create_dir_all(dir.join("src")).unwrap();
2369 fs::write(dir.join("Cargo.toml"), "").unwrap();
2370 fs::write(dir.join("src/main.rs"), "").unwrap();
2371 fs::write(dir.join("src/lib.rs"), "").unwrap();
2372
2373 let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
2374 let result = exec_glob(&args);
2375 assert!(result.contains("main.rs"), "{result}");
2376 assert!(result.contains("lib.rs"), "{result}");
2377 assert!(!result.contains("Cargo.toml"), "{result}");
2378
2379 let _ = fs::remove_dir_all(&dir);
2380 }
2381
2382 #[test]
2383 fn test_search_files_file_glob_filter() {
2384 let dir = std::env::temp_dir().join("sigit_test_search_glob");
2385 let _ = fs::remove_dir_all(&dir);
2386 fs::create_dir_all(&dir).unwrap();
2387 fs::write(dir.join("code.rs"), "needle here\n").unwrap();
2388 fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
2389
2390 let args = serde_json::json!({
2391 "pattern": "needle", "path": &dir, "file_glob": "*.rs"
2392 })
2393 .to_string();
2394 let result = exec_search_files(&args);
2395 assert!(result.contains("code.rs"), "{result}");
2396 assert!(!result.contains("notes.txt"), "{result}");
2397
2398 let _ = fs::remove_dir_all(&dir);
2399 }
2400
2401 #[test]
2402 fn test_write_todos_renders_checklist() {
2403 let args = serde_json::json!({
2404 "todos": [
2405 { "content": "Read code", "status": "completed" },
2406 { "content": "Make change", "status": "in_progress" },
2407 { "content": "Run tests", "status": "pending" }
2408 ]
2409 })
2410 .to_string();
2411 let result = exec_write_todos(&args);
2412 assert!(result.contains("1/3 done"), "{result}");
2413 assert!(result.contains("[x] Read code"), "{result}");
2414 assert!(result.contains("[~] Make change"), "{result}");
2415 assert!(result.contains("[ ] Run tests"), "{result}");
2416 }
2417
2418 #[test]
2419 fn test_remember_appends_to_instruction_file() {
2420 let dir = std::env::temp_dir().join("sigit_test_remember");
2421 let _ = fs::remove_dir_all(&dir);
2422 fs::create_dir_all(dir.join(".git")).unwrap();
2423 let claude_md = dir.join("CLAUDE.md");
2424 fs::write(&claude_md, "# Project\n").unwrap();
2425
2426 let target = crate::instructions::memory_file(&dir);
2427 // Should pick the existing CLAUDE.md at the repo root.
2428 assert_eq!(
2429 target.canonicalize().unwrap(),
2430 claude_md.canonicalize().unwrap()
2431 );
2432
2433 let result = remember_at(&dir, "remembered text");
2434 assert!(result.contains("remembered"), "{result}");
2435
2436 let updated = fs::read_to_string(&claude_md).unwrap();
2437 assert!(updated.contains("## Remembered notes"), "{updated}");
2438 assert!(updated.contains("- remembered text"), "{updated}");
2439
2440 let _ = fs::remove_dir_all(&dir);
2441 }
2442
2443 #[test]
2444 fn test_all_tools_schemas_are_valid_json_objects() {
2445 for tool in all_tools() {
2446 assert!(
2447 tool.parameters_schema.is_object(),
2448 "schema for {} is not an object",
2449 tool.name
2450 );
2451 let obj = tool.parameters_schema.as_object().unwrap();
2452 assert!(obj.contains_key("type"));
2453 assert!(obj.contains_key("properties"));
2454 assert!(obj.contains_key("required"));
2455 }
2456 }
2457
2458 // ── read_website tests ───────────────────────────────────────────────
2459
2460 #[test]
2461 fn test_read_website_missing_url() {
2462 let result = exec_read_website("{}");
2463 assert!(result.contains("missing required parameter"));
2464 }
2465
2466 #[test]
2467 fn test_read_website_invalid_scheme() {
2468 let result = exec_read_website(r#"{"url": "file:///tmp/test.html"}"#);
2469 assert!(result.contains("url must start with http:// or https://"));
2470 }
2471
2472 #[test]
2473 fn test_read_website_extracts_title_from_html() {
2474 let body = r#"
2475 <html>
2476 <head>
2477 <title>Qwen 3.6 27B</title>
2478 </head>
2479 <body>
2480 <h1>Model card</h1>
2481 <p>Large language model.</p>
2482 </body>
2483 </html>
2484 "#;
2485
2486 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
2487 .unwrap()
2488 .captures(body)
2489 .and_then(|captures| captures.get(1))
2490 .map(|m| {
2491 Regex::new(r"\s+")
2492 .unwrap()
2493 .replace_all(m.as_str(), " ")
2494 .trim()
2495 .to_string()
2496 })
2497 .filter(|title| !title.is_empty());
2498
2499 assert_eq!(title.as_deref(), Some("Qwen 3.6 27B"));
2500 }
2501
2502 #[test]
2503 fn test_read_website_metadata_includes_final_url_header() {
2504 let final_url = "https://huggingface.co/Qwen/Qwen3.6-27B";
2505 let title = Some("Qwen 3.6 27B".to_string());
2506 let cleaned = "Model card\nLarge language model.".to_string();
2507
2508 let mut metadata = vec![format!("URL: {final_url}")];
2509 if let Some(title) = &title {
2510 metadata.push(format!("Title: {title}"));
2511 }
2512
2513 let body_text = match title {
2514 Some(_) => cleaned,
2515 None => cleaned,
2516 };
2517
2518 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
2519
2520 assert!(output.starts_with("URL: https://huggingface.co/Qwen/Qwen3.6-27B"));
2521 assert!(output.contains("\nTitle: Qwen 3.6 27B\n\n"));
2522 }
2523
2524 // ── create_directory tests ───────────────────────────────────────────
2525
2526 #[test]
2527 fn test_create_directory_missing_path() {
2528 let result = exec_create_directory("{}");
2529 assert!(result.contains("missing required parameter"));
2530 }
2531
2532 #[test]
2533 fn test_create_directory_success() {
2534 let dir = std::env::temp_dir()
2535 .join("sigit_test_create_directory")
2536 .join("nested")
2537 .join("child");
2538 let _ = fs::remove_dir_all(dir.parent().unwrap());
2539
2540 let args = serde_json::json!({ "path": dir }).to_string();
2541 let result = exec_create_directory(&args);
2542 assert!(result.starts_with("Created directory:"), "got: {result}");
2543 assert!(dir.exists());
2544 assert!(dir.is_dir());
2545
2546 let _ = fs::remove_dir_all(dir.parent().unwrap().parent().unwrap());
2547 }
2548
2549 #[test]
2550 fn test_create_directory_already_exists() {
2551 let dir = std::env::temp_dir().join("sigit_test_create_directory_exists");
2552 let _ = fs::remove_dir_all(&dir);
2553 fs::create_dir_all(&dir).unwrap();
2554
2555 let args = serde_json::json!({ "path": dir }).to_string();
2556 let result = exec_create_directory(&args);
2557 assert!(result.contains("Directory already exists"), "got: {result}");
2558
2559 let _ = fs::remove_dir_all(&dir);
2560 }
2561
2562 // ── create_file tests ────────────────────────────────────────────────
2563
2564 #[test]
2565 fn test_create_file_missing_path() {
2566 let result = exec_create_file(r#"{"content": "hello"}"#);
2567 assert!(result.contains("missing required parameter"));
2568 }
2569
2570 #[test]
2571 fn test_create_file_missing_content() {
2572 let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#);
2573 assert!(result.contains("missing required parameter"));
2574 }
2575
2576 #[test]
2577 fn test_create_file_success() {
2578 let dir = std::env::temp_dir().join("sigit_test_create_file");
2579 let _ = fs::remove_dir_all(&dir);
2580
2581 let file_path = dir.join("sub").join("new_file.txt");
2582 let args = serde_json::json!({
2583 "path": file_path,
2584 "content": "hello world"
2585 })
2586 .to_string();
2587
2588 let result = exec_create_file(&args);
2589 assert!(result.starts_with("Created file:"), "got: {result}");
2590 assert!(file_path.exists());
2591 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
2592
2593 let _ = fs::remove_dir_all(&dir);
2594 }
2595
2596 #[test]
2597 fn test_create_file_already_exists() {
2598 let dir = std::env::temp_dir().join("sigit_test_create_exists");
2599 let _ = fs::remove_dir_all(&dir);
2600 fs::create_dir_all(&dir).unwrap();
2601
2602 let file_path = dir.join("existing.txt");
2603 fs::write(&file_path, "original").unwrap();
2604
2605 let args = serde_json::json!({
2606 "path": file_path,
2607 "content": "overwrite attempt"
2608 })
2609 .to_string();
2610
2611 let result = exec_create_file(&args);
2612 assert!(result.contains("already exists"), "got: {result}");
2613 // Original content untouched.
2614 assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
2615
2616 let _ = fs::remove_dir_all(&dir);
2617 }
2618
2619 // ── edit_file tests ──────────────────────────────────────────────────
2620
2621 #[test]
2622 fn test_edit_file_missing_params() {
2623 let result = exec_edit_file(r#"{"path": "x"}"#);
2624 assert!(result.contains("missing required parameter"));
2625
2626 let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#);
2627 assert!(result.contains("missing required parameter"));
2628 }
2629
2630 #[test]
2631 fn test_edit_file_nonexistent() {
2632 let result = exec_edit_file(
2633 r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#,
2634 );
2635 assert!(result.contains("does not exist"));
2636 }
2637
2638 #[test]
2639 fn test_edit_file_success() {
2640 let dir = std::env::temp_dir().join("sigit_test_edit_file");
2641 let _ = fs::remove_dir_all(&dir);
2642 fs::create_dir_all(&dir).unwrap();
2643
2644 let file_path = dir.join("code.rs");
2645 fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
2646
2647 let args = serde_json::json!({
2648 "path": file_path,
2649 "old_text": "println!(\"hello\")",
2650 "new_text": "println!(\"world\")"
2651 })
2652 .to_string();
2653
2654 let result = exec_edit_file(&args);
2655 assert!(result.starts_with("Edited file:"), "got: {result}");
2656
2657 let updated = fs::read_to_string(&file_path).unwrap();
2658 assert!(updated.contains("println!(\"world\")"));
2659 assert!(!updated.contains("println!(\"hello\")"));
2660
2661 let _ = fs::remove_dir_all(&dir);
2662 }
2663
2664 #[test]
2665 fn test_edit_file_old_text_not_found() {
2666 let dir = std::env::temp_dir().join("sigit_test_edit_notfound");
2667 let _ = fs::remove_dir_all(&dir);
2668 fs::create_dir_all(&dir).unwrap();
2669
2670 let file_path = dir.join("data.txt");
2671 fs::write(&file_path, "aaa bbb ccc").unwrap();
2672
2673 let args = serde_json::json!({
2674 "path": file_path,
2675 "old_text": "zzz",
2676 "new_text": "yyy"
2677 })
2678 .to_string();
2679
2680 let result = exec_edit_file(&args);
2681 assert!(result.contains("old_text not found"), "got: {result}");
2682
2683 let _ = fs::remove_dir_all(&dir);
2684 }
2685
2686 #[test]
2687 fn test_edit_file_ambiguous_match() {
2688 let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous");
2689 let _ = fs::remove_dir_all(&dir);
2690 fs::create_dir_all(&dir).unwrap();
2691
2692 let file_path = dir.join("repeat.txt");
2693 fs::write(&file_path, "foo bar foo bar foo").unwrap();
2694
2695 let args = serde_json::json!({
2696 "path": file_path,
2697 "old_text": "foo",
2698 "new_text": "baz"
2699 })
2700 .to_string();
2701
2702 let result = exec_edit_file(&args);
2703 assert!(result.contains("appears 3 times"), "got: {result}");
2704 // File should be unchanged.
2705 assert_eq!(
2706 fs::read_to_string(&file_path).unwrap(),
2707 "foo bar foo bar foo"
2708 );
2709
2710 let _ = fs::remove_dir_all(&dir);
2711 }
2712
2713 // ── delete_file tests ────────────────────────────────────────────────
2714
2715 #[test]
2716 fn test_delete_file_missing_path() {
2717 let result = exec_delete_file("{}");
2718 assert!(
2719 result.contains("missing required parameter"),
2720 "got: {result}"
2721 );
2722 }
2723
2724 #[test]
2725 fn test_delete_file_nonexistent() {
2726 let result = exec_delete_file(r#"{"path": "/tmp/sigit_test_no_such_file_xyz"}"#);
2727 assert!(result.contains("does not exist"), "got: {result}");
2728 }
2729
2730 #[test]
2731 fn test_delete_file_success() {
2732 let dir = std::env::temp_dir().join("sigit_test_delete_file");
2733 let _ = fs::remove_dir_all(&dir);
2734 fs::create_dir_all(&dir).unwrap();
2735
2736 let file_path = dir.join("to_delete.txt");
2737 fs::write(&file_path, "bye").unwrap();
2738 assert!(file_path.exists());
2739
2740 let args = serde_json::json!({ "path": file_path }).to_string();
2741 let result = exec_delete_file(&args);
2742 assert!(result.contains("Deleted file"), "got: {result}");
2743 assert!(!file_path.exists());
2744
2745 let _ = fs::remove_dir_all(&dir);
2746 }
2747
2748 #[test]
2749 fn test_delete_empty_directory() {
2750 let dir = std::env::temp_dir().join("sigit_test_delete_empty_dir");
2751 let _ = fs::remove_dir_all(&dir);
2752 fs::create_dir_all(&dir).unwrap();
2753
2754 let args = serde_json::json!({ "path": dir }).to_string();
2755 let result = exec_delete_file(&args);
2756 assert!(result.contains("Deleted empty directory"), "got: {result}");
2757 assert!(!dir.exists());
2758 }
2759
2760 #[test]
2761 fn test_delete_nonempty_directory() {
2762 let dir = std::env::temp_dir().join("sigit_test_delete_nonempty_dir");
2763 let _ = fs::remove_dir_all(&dir);
2764 fs::create_dir_all(&dir).unwrap();
2765 fs::write(dir.join("child.txt"), "content").unwrap();
2766
2767 let args = serde_json::json!({ "path": dir }).to_string();
2768 let result = exec_delete_file(&args);
2769 assert!(result.contains("Error"), "got: {result}");
2770 assert!(dir.exists(), "directory should not have been deleted");
2771
2772 let _ = fs::remove_dir_all(&dir);
2773 }
2774
2775 // ── run_command tests ────────────────────────────────────────────────
2776
2777 #[test]
2778 fn test_run_command_missing_command() {
2779 let result = exec_run_command("{}");
2780 assert!(
2781 result.contains("missing required parameter"),
2782 "got: {result}"
2783 );
2784 }
2785
2786 #[test]
2787 fn test_run_command_success() {
2788 let result = exec_run_command(r#"{"command": "echo hello"}"#);
2789 assert!(result.contains("hello"), "got: {result}");
2790 assert!(result.contains("Exit code 0"), "got: {result}");
2791 }
2792
2793 /// Fresh git repo with one commit, test identity, and signing off (the
2794 /// developer's global gpgsign must not leak into sandbox commits).
2795 fn init_test_repo(name: &str) -> std::path::PathBuf {
2796 let dir = std::env::temp_dir().join(format!("sigit_test_{name}_{}", std::process::id()));
2797 let _ = fs::remove_dir_all(&dir);
2798 fs::create_dir_all(&dir).unwrap();
2799 test_git(&dir, &["init", "-q", "-b", "main"]);
2800 test_git(&dir, &["config", "user.name", "Test User"]);
2801 test_git(&dir, &["config", "user.email", "test@example.com"]);
2802 test_git(&dir, &["config", "commit.gpgsign", "false"]);
2803 fs::write(dir.join("file.txt"), "one\n").unwrap();
2804 test_git(&dir, &["add", "file.txt"]);
2805 test_git(&dir, &["commit", "-q", "-m", "Initial"]);
2806 dir
2807 }
2808
2809 fn test_git(dir: &Path, args: &[&str]) {
2810 let out = Command::new("git")
2811 .args(args)
2812 .current_dir(dir)
2813 .output()
2814 .unwrap();
2815 assert!(
2816 out.status.success(),
2817 "git {args:?} failed: {}",
2818 String::from_utf8_lossy(&out.stderr)
2819 );
2820 }
2821
2822 #[test]
2823 fn run_command_appends_co_author_trailer_to_new_commits() {
2824 let dir = init_test_repo("coauthor_append");
2825 fs::write(dir.join("file.txt"), "two\n").unwrap();
2826 // Quote-free command: `cmd /C` does not strip double quotes the way
2827 // `sh -c` does, so quoted arguments would break on Windows.
2828 let args = serde_json::json!({
2829 "command": "git add file.txt && git commit -m Update",
2830 "cwd": dir.display().to_string(),
2831 })
2832 .to_string();
2833
2834 let result = exec_run_command(&args);
2835 assert!(result.contains("co-author trailer"), "got: {result}");
2836
2837 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2838 assert!(
2839 message.ends_with(COMMIT_CO_AUTHOR_TRAILER),
2840 "trailer must be the last line: {message:?}"
2841 );
2842 assert!(
2843 message.contains(&format!("\n\n{COMMIT_CO_AUTHOR_TRAILER}")),
2844 "trailer needs a blank line before it for GitHub to detect it: {message:?}"
2845 );
2846 let _ = fs::remove_dir_all(&dir);
2847 }
2848
2849 #[test]
2850 fn run_command_keeps_existing_co_author_trailer() {
2851 let dir = init_test_repo("coauthor_present");
2852 fs::write(dir.join("file.txt"), "two\n").unwrap();
2853 // The trailer contains spaces and angle brackets, which `cmd /C`
2854 // mis-tokenizes (`<` is redirection), so create the trailer-carrying
2855 // commit with direct git args and let run_command amend it without
2856 // editing: HEAD changes, the message already has the trailer, and the
2857 // gate must leave it alone.
2858 test_git(&dir, &["add", "file.txt"]);
2859 test_git(
2860 &dir,
2861 &[
2862 "commit",
2863 "-q",
2864 "-m",
2865 &format!("Update file\n\n{COMMIT_CO_AUTHOR_TRAILER}"),
2866 ],
2867 );
2868 let args = serde_json::json!({
2869 "command": "git commit --amend --no-edit",
2870 "cwd": dir.display().to_string(),
2871 })
2872 .to_string();
2873
2874 let result = exec_run_command(&args);
2875 assert!(
2876 !result.contains("[siGit Code]"),
2877 "no amend expected: {result}"
2878 );
2879
2880 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2881 assert_eq!(
2882 message.matches("Co-Authored-By: siGit Code").count(),
2883 1,
2884 "trailer must not be duplicated: {message:?}"
2885 );
2886 let _ = fs::remove_dir_all(&dir);
2887 }
2888
2889 #[test]
2890 fn run_command_never_amends_pushed_commits() {
2891 let dir = init_test_repo("coauthor_pushed");
2892 let remote = std::env::temp_dir().join(format!(
2893 "sigit_test_coauthor_remote_{}.git",
2894 std::process::id()
2895 ));
2896 let _ = fs::remove_dir_all(&remote);
2897 fs::create_dir_all(&remote).unwrap();
2898 test_git(&remote, &["init", "-q", "--bare"]);
2899 test_git(&dir, &["remote", "add", "origin", remote.to_str().unwrap()]);
2900
2901 fs::write(dir.join("file.txt"), "two\n").unwrap();
2902 let args = serde_json::json!({
2903 "command": "git add file.txt && git commit -m Update && git push -q origin main",
2904 "cwd": dir.display().to_string(),
2905 })
2906 .to_string();
2907
2908 let result = exec_run_command(&args);
2909 assert!(
2910 !result.contains("[siGit Code]"),
2911 "no amend expected: {result}"
2912 );
2913
2914 // Already on the remote when the gate ran, so it must be untouched.
2915 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2916 assert!(
2917 !message.contains("Co-Authored-By"),
2918 "pushed commit must not be rewritten: {message:?}"
2919 );
2920 let _ = fs::remove_dir_all(&dir);
2921 let _ = fs::remove_dir_all(&remote);
2922 }
2923
2924 #[test]
2925 fn test_run_command_failure() {
2926 #[cfg(unix)]
2927 let command = "false";
2928 #[cfg(windows)]
2929 let command = "exit /b 1";
2930
2931 let args = serde_json::json!({ "command": command }).to_string();
2932 let result = exec_run_command(&args);
2933 assert!(result.contains("failed"), "got: {result}");
2934 }
2935
2936 #[test]
2937 fn test_run_command_with_cwd() {
2938 let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
2939 let _ = fs::remove_dir_all(&dir);
2940 fs::create_dir_all(&dir).unwrap();
2941
2942 #[cfg(unix)]
2943 let command = "pwd";
2944 #[cfg(windows)]
2945 let command = "cd";
2946
2947 let args = serde_json::json!({
2948 "command": command,
2949 "cwd": dir
2950 })
2951 .to_string();
2952 let result = exec_run_command(&args);
2953 // The output should contain the temp dir path.
2954 assert!(
2955 result.contains(&dir.to_string_lossy().to_string()),
2956 "got: {result}"
2957 );
2958
2959 let _ = fs::remove_dir_all(&dir);
2960 }
2961
2962 #[test]
2963 fn test_run_command_bad_cwd() {
2964 let missing_dir = std::env::temp_dir().join("sigit_no_such_dir_xyz");
2965 let _ = fs::remove_dir_all(&missing_dir);
2966
2967 let args = serde_json::json!({
2968 "command": "echo hi",
2969 "cwd": missing_dir
2970 })
2971 .to_string();
2972 let result = exec_run_command(&args);
2973 assert!(result.contains("does not exist"), "got: {result}");
2974 }
2975
2976 #[test]
2977 fn test_run_command_captures_stderr() {
2978 #[cfg(unix)]
2979 let command = "echo err >&2";
2980 #[cfg(windows)]
2981 let command = "echo err 1>&2";
2982
2983 let args = serde_json::json!({ "command": command }).to_string();
2984 let result = exec_run_command(&args);
2985 assert!(result.contains("err"), "got: {result}");
2986 }
2987
2988 // ── background command tests ─────────────────────────────────────────
2989
2990 /// Extract the task id from a "Started background task {id}: ..." result.
2991 fn background_task_id(result: &str) -> u64 {
2992 let digits: String = result
2993 .strip_prefix("Started background task ")
2994 .unwrap_or_else(|| panic!("unexpected spawn result: {result}"))
2995 .chars()
2996 .take_while(char::is_ascii_digit)
2997 .collect();
2998 digits.parse().expect("task id")
2999 }
3000
3001 /// Whether the task's child has exited, without draining its output.
3002 fn background_task_exited(task_id: u64) -> bool {
3003 let mut map = lock_tasks();
3004 match map.get_mut(&task_id) {
3005 Some(task) => poll_exit_code(task).is_some(),
3006 None => true,
3007 }
3008 }
3009
3010 /// Wait (bounded) for a background task's child to exit.
3011 fn wait_for_background_exit(task_id: u64) {
3012 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
3013 while !background_task_exited(task_id) {
3014 assert!(
3015 std::time::Instant::now() < deadline,
3016 "task {task_id} did not exit in time"
3017 );
3018 std::thread::sleep(std::time::Duration::from_millis(50));
3019 }
3020 }
3021
3022 #[test]
3023 fn test_run_command_background_lifecycle() {
3024 #[cfg(unix)]
3025 let command = "echo start; sleep 2; echo done";
3026 #[cfg(windows)]
3027 let command = "echo start&& ping -n 3 127.0.0.1 > nul&& echo done";
3028
3029 let args = serde_json::json!({
3030 "command": command,
3031 "cwd": std::env::temp_dir(),
3032 "run_in_background": true
3033 })
3034 .to_string();
3035 let spawned = std::time::Instant::now();
3036 let result = exec_run_command(&args);
3037 // Spawning must return immediately, not wait the ~2s the command takes.
3038 assert!(
3039 spawned.elapsed() < std::time::Duration::from_secs(1),
3040 "background spawn blocked for {:?}",
3041 spawned.elapsed()
3042 );
3043 assert!(result.contains("command_output"), "got: {result}");
3044 let task_id = background_task_id(&result);
3045
3046 // Polling while the command is still sleeping reports it as running.
3047 let poll_args = serde_json::json!({ "task_id": task_id }).to_string();
3048 let poll = exec_command_output(&poll_args);
3049 assert!(poll.contains("still running"), "got: {poll}");
3050 let mut combined = poll;
3051
3052 wait_for_background_exit(task_id);
3053 // Grace period so the reader threads finish draining the pipes.
3054 std::thread::sleep(std::time::Duration::from_millis(300));
3055 let final_poll = exec_command_output(&poll_args);
3056 assert!(
3057 final_poll.contains("exited with code 0"),
3058 "got: {final_poll}"
3059 );
3060 combined.push_str(&final_poll);
3061
3062 // Across the polls, all output the command printed was delivered.
3063 assert!(combined.contains("start"), "got: {combined}");
3064 assert!(combined.contains("done"), "got: {combined}");
3065 }
3066
3067 #[test]
3068 fn test_kill_command_stops_background_task() {
3069 #[cfg(unix)]
3070 let command = "sleep 30";
3071 #[cfg(windows)]
3072 let command = "ping -n 31 127.0.0.1 > nul";
3073
3074 let args = serde_json::json!({
3075 "command": command,
3076 "cwd": std::env::temp_dir(),
3077 "run_in_background": true
3078 })
3079 .to_string();
3080 let result = exec_run_command(&args);
3081 let task_id = background_task_id(&result);
3082
3083 let kill_args = serde_json::json!({ "task_id": task_id }).to_string();
3084 let killed_at = std::time::Instant::now();
3085 let kill_result = exec_kill_command(&kill_args);
3086 assert!(
3087 kill_result.contains(&format!("Killed task {task_id}")),
3088 "got: {kill_result}"
3089 );
3090 // The kill must not wait out the 30s sleep.
3091 assert!(
3092 killed_at.elapsed() < std::time::Duration::from_secs(5),
3093 "kill blocked for {:?}",
3094 killed_at.elapsed()
3095 );
3096
3097 // A later poll reports the task as killed, not still running.
3098 let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
3099 assert!(poll.contains("was killed"), "got: {poll}");
3100 }
3101
3102 #[test]
3103 fn test_command_output_unknown_task() {
3104 let result = exec_command_output(r#"{"task_id": 9999999}"#);
3105 assert!(
3106 result.contains("no background task with id"),
3107 "got: {result}"
3108 );
3109 }
3110
3111 // ── task (subagent) tests ────────────────────────────────────────────
3112
3113 #[test]
3114 fn test_subagent_toolset_is_read_only() {
3115 let specs = subagent_tool_specs();
3116 let mut names: Vec<&str> = specs.iter().map(|spec| spec.name.as_str()).collect();
3117 names.sort_unstable();
3118 assert_eq!(
3119 names,
3120 [
3121 "glob",
3122 "list_directory",
3123 "read_file",
3124 "read_website",
3125 "search_files"
3126 ]
3127 );
3128
3129 // No recursion and no mutating tools, ever.
3130 assert!(!names.contains(&TASK_TOOL_NAME));
3131 for banned in [
3132 "create_file",
3133 "create_directory",
3134 "edit_file",
3135 "multi_edit",
3136 "delete_file",
3137 "run_command",
3138 "write_todos",
3139 "remember",
3140 ] {
3141 assert!(!names.contains(&banned), "{banned} leaked into subagent");
3142 }
3143
3144 // Every spec came from `all_tools()` (schemas intact).
3145 for spec in &specs {
3146 assert!(
3147 serde_json::from_str::<Value>(&spec.parameters_schema)
3148 .unwrap()
3149 .is_object()
3150 );
3151 }
3152 }
3153
3154 #[tokio::test]
3155 async fn test_task_reports_unavailable_without_factory() {
3156 let args = serde_json::json!({
3157 "description": "look around",
3158 "prompt": "What is in the current directory?"
3159 })
3160 .to_string();
3161 // `None` is exactly what `exec_task` passes before any surface has
3162 // registered the process-global factory.
3163 let result = exec_task_with(&args, None).await;
3164 assert!(
3165 result.contains("not available on-device yet"),
3166 "got: {result}"
3167 );
3168 }
3169
3170 #[test]
3171 fn test_kill_command_unknown_task() {
3172 let result = exec_kill_command(r#"{"task_id": 9999999}"#);
3173 assert!(
3174 result.contains("no background task with id"),
3175 "got: {result}"
3176 );
3177 }
3178
3179 #[tokio::test]
3180 async fn test_task_missing_prompt() {
3181 let result = exec_task_with(r#"{"description": "x"}"#, None).await;
3182 assert!(
3183 result.contains("missing required parameter \"prompt\""),
3184 "got: {result}"
3185 );
3186 }
3187
3188 #[test]
3189 fn test_command_output_missing_task_id() {
3190 let result = exec_command_output("{}");
3191 assert!(
3192 result.contains("missing required parameter"),
3193 "got: {result}"
3194 );
3195 }
3196
3197 #[test]
3198 fn test_background_output_is_capped() {
3199 // Print well over COMMAND_OUTPUT_LIMIT (50 000) bytes without polling,
3200 // so the buffer must drop the oldest output and note the truncation.
3201 #[cfg(unix)]
3202 let command = r#"i=0; while [ $i -lt 200 ]; do printf 'line-%04d %s\n' "$i" \
3203 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3204 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3205 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3206 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3207 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; \
3208 i=$((i+1)); done"#;
3209 // Quote-free on purpose: `cmd /C` does not strip double quotes the
3210 // way `sh -c` does, so a quoted PowerShell one-liner gets mangled.
3211 // A cmd-native for /L loop needs no quoting at all.
3212 #[cfg(windows)]
3213 let command = &format!(
3214 "for /L %i in (1,1,200) do @echo line-%i-end {}",
3215 "a".repeat(330)
3216 );
3217
3218 let args = serde_json::json!({
3219 "command": command,
3220 "cwd": std::env::temp_dir(),
3221 "run_in_background": true
3222 })
3223 .to_string();
3224 let result = exec_run_command(&args);
3225 let task_id = background_task_id(&result);
3226
3227 wait_for_background_exit(task_id);
3228 // Grace period so the reader threads finish draining the pipes.
3229 std::thread::sleep(std::time::Duration::from_millis(300));
3230
3231 let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
3232 assert!(poll.contains("exited with code 0"), "got: {poll}");
3233 assert!(
3234 poll.contains("earlier output was dropped"),
3235 "expected truncation note, got: {poll}"
3236 );
3237 // The oldest lines were dropped; the newest survived. The two
3238 // platforms number lines differently (printf %04d vs cmd's %i).
3239 #[cfg(unix)]
3240 let (oldest, newest) = ("line-0000", "line-0199");
3241 #[cfg(windows)]
3242 let (oldest, newest) = ("line-1-end", "line-200-end");
3243 assert!(!poll.contains(oldest), "oldest output not dropped");
3244 assert!(poll.contains(newest), "newest output missing");
3245 // Buffer stayed within the cap, plus the framing: the status line
3246 // (which quotes the command) and the truncation note.
3247 assert!(
3248 poll.len() <= COMMAND_OUTPUT_LIMIT + command.len() + 500,
3249 "poll result too large: {} bytes",
3250 poll.len()
3251 );
3252 }
3253
3254 // ── task (subagent) end-to-end against a scripted endpoint ──────────
3255
3256 /// A completion whose assistant message requests one tool call.
3257 fn completion_tool_call(id: &str, name: &str, arguments: &str) -> String {
3258 serde_json::json!({
3259 "choices": [{"message": {
3260 "role": "assistant",
3261 "content": serde_json::Value::Null,
3262 "tool_calls": [{
3263 "id": id,
3264 "type": "function",
3265 "function": {"name": name, "arguments": arguments},
3266 }],
3267 }}]
3268 })
3269 .to_string()
3270 }
3271
3272 /// A completion whose assistant message is a plain text answer.
3273 fn completion_text(text: &str) -> String {
3274 serde_json::json!({
3275 "choices": [{"message": {"role": "assistant", "content": text}}]
3276 })
3277 .to_string()
3278 }
3279
3280 /// Minimal scripted OpenAI-compatible endpoint (same pattern as
3281 /// `tests/acp_permissions.rs`): serves one canned chat-completion JSON body
3282 /// per request and records each request body. The subagent loop passes
3283 /// `sink: None`, so the backend takes the non-streaming path and expects
3284 /// plain JSON rather than SSE.
3285 fn start_scripted_endpoint(responses: Vec<String>) -> (u16, Arc<std::sync::Mutex<Vec<Value>>>) {
3286 use std::io::{BufRead, BufReader, Read, Write};
3287
3288 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind endpoint");
3289 let port = listener.local_addr().unwrap().port();
3290 let requests: Arc<std::sync::Mutex<Vec<Value>>> = Arc::default();
3291 let recorded = Arc::clone(&requests);
3292 let queue = std::sync::Mutex::new(std::collections::VecDeque::from(responses));
3293
3294 std::thread::spawn(move || {
3295 // `connection: close` means one request per connection, matching
3296 // the backend's serial completion requests.
3297 for stream in listener.incoming() {
3298 let Ok(mut stream) = stream else { continue };
3299 let mut reader = BufReader::new(match stream.try_clone() {
3300 Ok(clone) => clone,
3301 Err(_) => continue,
3302 });
3303 let mut content_length = 0usize;
3304 loop {
3305 let mut line = String::new();
3306 if reader.read_line(&mut line).unwrap_or(0) == 0 {
3307 break;
3308 }
3309 let line = line.trim();
3310 if line.is_empty() {
3311 break;
3312 }
3313 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:")
3314 {
3315 content_length = length.trim().parse().unwrap_or(0);
3316 }
3317 }
3318 let mut body = vec![0u8; content_length];
3319 if reader.read_exact(&mut body).is_err() {
3320 continue;
3321 }
3322 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
3323 recorded.lock().unwrap().push(request);
3324 }
3325 let payload = queue
3326 .lock()
3327 .unwrap()
3328 .pop_front()
3329 .unwrap_or_else(|| completion_text("out of scripted responses"));
3330 let response = format!(
3331 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
3332 content-length: {}\r\nconnection: close\r\n\r\n{}",
3333 payload.len(),
3334 payload
3335 );
3336 let _ = stream.write_all(response.as_bytes());
3337 }
3338 });
3339
3340 (port, requests)
3341 }
3342
3343 #[tokio::test]
3344 async fn test_task_runs_subagent_end_to_end() {
3345 // A file only the subagent's read_file call can surface.
3346 let dir = std::env::temp_dir().join("sigit_test_subagent_e2e");
3347 let _ = fs::remove_dir_all(&dir);
3348 fs::create_dir_all(&dir).unwrap();
3349 let file = dir.join("notes.txt");
3350 fs::write(&file, "subagent secret: 4217").unwrap();
3351
3352 // Script: one read_file tool call, then a final text answer.
3353 let (port, requests) = start_scripted_endpoint(vec![
3354 completion_tool_call(
3355 "call_1",
3356 "read_file",
3357 &serde_json::json!({ "path": file }).to_string(),
3358 ),
3359 completion_text("The file contains the number 4217."),
3360 ]);
3361
3362 // Register the real process-global factory, pointing a fresh
3363 // OpenAiBackend at the scripted endpoint — exactly what the surfaces
3364 // do at startup. This is the only test that touches the OnceLock.
3365 set_subagent_factory(Box::new(move || {
3366 Some(Arc::new(crate::backend::OpenAiBackend::new(
3367 format!("http://127.0.0.1:{port}"),
3368 "test-key",
3369 "scripted-model",
3370 Some(SUBAGENT_SYSTEM_PROMPT.to_string()),
3371 )) as Arc<dyn InferenceBackend>)
3372 }));
3373 assert!(subagent_available());
3374
3375 let args = serde_json::json!({
3376 "description": "read the notes file",
3377 "prompt": format!("What number is recorded in {}?", file.display()),
3378 })
3379 .to_string();
3380 let result = execute_tool(TASK_TOOL_NAME, &args).await;
3381
3382 // Only the subagent's final text comes back.
3383 assert_eq!(result, "The file contains the number 4217.");
3384
3385 let recorded = requests.lock().unwrap();
3386 assert_eq!(recorded.len(), 2, "expected exactly two completions");
3387
3388 // The subagent conversation is fresh (subagent system prompt) and was
3389 // offered only the read-only toolset.
3390 let first = &recorded[0];
3391 assert_eq!(first["messages"][0]["role"], "system");
3392 assert!(
3393 first["messages"][0]["content"]
3394 .as_str()
3395 .unwrap()
3396 .contains("research subagent")
3397 );
3398 let offered: Vec<&str> = first["tools"]
3399 .as_array()
3400 .unwrap()
3401 .iter()
3402 .map(|tool| tool["function"]["name"].as_str().unwrap())
3403 .collect();
3404 for name in &offered {
3405 assert!(
3406 SUBAGENT_TOOL_NAMES.contains(name),
3407 "non-read-only tool offered: {name}"
3408 );
3409 }
3410 assert!(!offered.contains(&TASK_TOOL_NAME));
3411
3412 // The read-only tool actually executed: its output travelled back to
3413 // the endpoint as a tool result on the second request.
3414 let messages = recorded[1]["messages"].as_array().unwrap();
3415 let tool_message = messages
3416 .iter()
3417 .find(|message| message["role"] == "tool")
3418 .expect("no tool result in second request");
3419 assert_eq!(tool_message["tool_call_id"], "call_1");
3420 assert!(
3421 tool_message["content"]
3422 .as_str()
3423 .unwrap()
3424 .contains("subagent secret: 4217")
3425 );
3426
3427 let _ = fs::remove_dir_all(&dir);
3428 }
3429 }