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