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 next_tools = if round < SUBAGENT_MAX_TOOL_ROUNDS {
943 Some(specs.as_slice())
944 } else {
945 None
946 };
947 result = match backend
948 .send_tool_results(tool_results, next_tools, None)
949 .await
950 {
951 Ok(r) => r,
952 Err(err) => return format!("Error: subagent inference failed: {err}"),
953 };
954 }
955
956 let text = result.text.trim();
957 if text.is_empty() {
958 return "The subagent finished without a text answer.".to_string();
959 }
960
961 let total = text.chars().count();
962 if total > SUBAGENT_RESULT_CHAR_LIMIT {
963 let truncated: String = text.chars().take(SUBAGENT_RESULT_CHAR_LIMIT).collect();
964 return format!(
965 "{truncated}\n\n--- truncated (showing {SUBAGENT_RESULT_CHAR_LIMIT} of {total} \
966 characters of the subagent's answer) ---"
967 );
968 }
969 text.to_string()
970 }
971
972 fn absolute_path(path: &Path) -> PathBuf {
973 if path.is_absolute() {
974 path.to_path_buf()
975 } else {
976 std::env::current_dir()
977 .unwrap_or_else(|_| PathBuf::from("."))
978 .join(path)
979 }
980 }
981
982 fn absolute_path_string(path: &Path) -> String {
983 absolute_path(path).display().to_string()
984 }
985
986 // ── read_file ────────────────────────────────────────────────────────────────
987
988 fn exec_read_file(arguments: &str) -> String {
989 let args: Value = match serde_json::from_str(arguments) {
990 Ok(v) => v,
991 Err(err) => return format!("Error: failed to parse arguments: {err}"),
992 };
993
994 let path_str = match args.get("path").and_then(Value::as_str) {
995 Some(p) => p,
996 None => return "Error: missing required parameter \"path\"".to_string(),
997 };
998
999 let start_line = args
1000 .get("start_line")
1001 .and_then(Value::as_u64)
1002 .map(|n| n as usize);
1003 let end_line = args
1004 .get("end_line")
1005 .and_then(Value::as_u64)
1006 .map(|n| n as usize);
1007
1008 let path = Path::new(path_str);
1009 let absolute_path = absolute_path(path);
1010 let absolute_path_str = absolute_path.display().to_string();
1011
1012 if !absolute_path.exists() {
1013 return format!("Error: path does not exist: {absolute_path_str}");
1014 }
1015
1016 if !absolute_path.is_file() {
1017 return format!("Error: path is not a file: {absolute_path_str}");
1018 }
1019
1020 match fs::read_to_string(&absolute_path) {
1021 Ok(contents) => {
1022 if start_line.is_some() || end_line.is_some() {
1023 let lines: Vec<&str> = contents.lines().collect();
1024 let total = lines.len();
1025 let start = start_line.unwrap_or(1).max(1);
1026 let end = end_line.unwrap_or(total).min(total);
1027
1028 if start > total {
1029 return format!(
1030 "Error: start_line {start} is beyond end of file ({total} lines)"
1031 );
1032 }
1033
1034 let selected: Vec<&str> = lines[(start - 1)..end].to_vec();
1035 let range_text = selected.join("\n");
1036 format!("Lines {start}-{end} of {total} in {absolute_path_str}:\n{range_text}")
1037 } else if contents.len() > READ_FILE_CHAR_LIMIT {
1038 let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect();
1039 format!(
1040 "{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---",
1041 contents.len()
1042 )
1043 } else {
1044 contents
1045 }
1046 }
1047 Err(err) => format!("Error: could not read file: {err}"),
1048 }
1049 }
1050
1051 // ── list_directory ───────────────────────────────────────────────────────────
1052
1053 fn exec_list_directory(arguments: &str) -> String {
1054 let args: Value = match serde_json::from_str(arguments) {
1055 Ok(v) => v,
1056 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1057 };
1058
1059 let path_str = match args.get("path").and_then(Value::as_str) {
1060 Some(p) => p,
1061 None => return "Error: missing required parameter \"path\"".to_string(),
1062 };
1063
1064 let path = Path::new(path_str);
1065 let absolute_path = absolute_path(path);
1066 let absolute_path_str = absolute_path.display().to_string();
1067
1068 if !absolute_path.exists() {
1069 return format!("Error: path does not exist: {absolute_path_str}");
1070 }
1071
1072 if !absolute_path.is_dir() {
1073 return format!("Error: path is not a directory: {absolute_path_str}");
1074 }
1075
1076 let entries = match fs::read_dir(&absolute_path) {
1077 Ok(rd) => rd,
1078 Err(err) => return format!("Error: could not read directory: {err}"),
1079 };
1080
1081 let mut dirs: Vec<String> = Vec::new();
1082 let mut files: Vec<String> = Vec::new();
1083
1084 for entry in entries {
1085 let entry = match entry {
1086 Ok(e) => e,
1087 Err(err) => {
1088 files.push(format!("[ERR] {err}"));
1089 continue;
1090 }
1091 };
1092
1093 let name = entry.file_name().to_string_lossy().to_string();
1094
1095 let is_dir = match entry.file_type() {
1096 Ok(ft) => ft.is_dir(),
1097 Err(_) => false,
1098 };
1099
1100 if is_dir {
1101 dirs.push(format!("[DIR] {name}"));
1102 } else {
1103 files.push(format!("[FILE] {name}"));
1104 }
1105 }
1106
1107 dirs.sort();
1108 files.sort();
1109
1110 dirs.extend(files);
1111
1112 if dirs.is_empty() {
1113 return format!("(empty directory: {absolute_path_str})");
1114 }
1115
1116 dirs.join("\n")
1117 }
1118
1119 // ── search_files ─────────────────────────────────────────────────────────────
1120
1121 fn exec_search_files(arguments: &str) -> String {
1122 let args: Value = match serde_json::from_str(arguments) {
1123 Ok(v) => v,
1124 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1125 };
1126
1127 let pattern_str = match args.get("pattern").and_then(Value::as_str) {
1128 Some(p) => p,
1129 None => return "Error: missing required parameter \"pattern\"".to_string(),
1130 };
1131
1132 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1133
1134 let re = match Regex::new(pattern_str) {
1135 Ok(r) => r,
1136 Err(err) => return format!("Error: invalid regex pattern: {err}"),
1137 };
1138
1139 // Optional file-name filter compiled from a glob (e.g. "*.rs").
1140 let name_filter = match args.get("file_glob").and_then(Value::as_str) {
1141 Some(glob) => match Regex::new(&glob_to_regex(glob)) {
1142 Ok(r) => Some(r),
1143 Err(err) => return format!("Error: invalid file_glob: {err}"),
1144 },
1145 None => None,
1146 };
1147
1148 let limit = args
1149 .get("max_results")
1150 .and_then(Value::as_u64)
1151 .map(|n| (n as usize).clamp(1, SEARCH_RESULTS_HARD_CAP))
1152 .unwrap_or(SEARCH_FILES_MATCH_LIMIT);
1153
1154 let root = Path::new(root_str);
1155 let absolute_root = absolute_path(root);
1156 let absolute_root_str = absolute_root.display().to_string();
1157
1158 if !absolute_root.exists() {
1159 return format!("Error: path does not exist: {absolute_root_str}");
1160 }
1161
1162 if !absolute_root.is_dir() {
1163 return format!("Error: path is not a directory: {absolute_root_str}");
1164 }
1165
1166 let mut matches: Vec<String> = Vec::new();
1167 walk_and_search(
1168 &absolute_root,
1169 &re,
1170 name_filter.as_ref(),
1171 limit,
1172 &mut matches,
1173 );
1174
1175 if matches.is_empty() {
1176 return format!("No matches found for pattern: {pattern_str}");
1177 }
1178
1179 let total = matches.len();
1180 if total > limit {
1181 matches.truncate(limit);
1182 matches.push(format!(
1183 "\n--- truncated (showing {limit} of {total}+ matches; raise max_results to see more) ---"
1184 ));
1185 }
1186
1187 matches.join("\n")
1188 }
1189
1190 /// Collects up to `limit + 1` matches (the extra signals truncation) so a broad
1191 /// pattern can't walk an entire tree once enough hits are found.
1192 fn walk_and_search(
1193 dir: &Path,
1194 re: &Regex,
1195 name_filter: Option<&Regex>,
1196 limit: usize,
1197 matches: &mut Vec<String>,
1198 ) {
1199 let entries = match fs::read_dir(dir) {
1200 Ok(rd) => rd,
1201 Err(_) => return,
1202 };
1203
1204 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1205 sorted.sort_by_key(|e| e.file_name());
1206
1207 for entry in sorted {
1208 if matches.len() > limit {
1209 return;
1210 }
1211
1212 let path = entry.path();
1213 let name = entry.file_name();
1214 let name_str = name.to_string_lossy();
1215
1216 if name_str.starts_with('.') {
1217 continue;
1218 }
1219
1220 if path.is_dir() {
1221 walk_and_search(&path, re, name_filter, limit, matches);
1222 } else if path.is_file() {
1223 if let Some(filter) = name_filter
1224 && !filter.is_match(&name_str)
1225 {
1226 continue;
1227 }
1228 search_file(&path, re, matches);
1229 }
1230 }
1231 }
1232
1233 /// skips non-UTF-8 files (probably binary).
1234 fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
1235 let contents = match fs::read_to_string(path) {
1236 Ok(c) => c,
1237 Err(_) => return,
1238 };
1239
1240 let display_path = absolute_path_string(path);
1241
1242 for (line_idx, line) in contents.lines().enumerate() {
1243 if re.is_match(line) {
1244 let line_number = line_idx + 1;
1245 matches.push(format!("{display_path}:{line_number}: {line}"));
1246 }
1247 }
1248 }
1249
1250 // ── read_website ─────────────────────────────────────────────────────────────
1251
1252 fn exec_read_website(arguments: &str) -> String {
1253 let args: Value = match serde_json::from_str(arguments) {
1254 Ok(v) => v,
1255 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1256 };
1257
1258 let url = match args.get("url").and_then(Value::as_str) {
1259 Some(u) => u,
1260 None => return "Error: missing required parameter \"url\"".to_string(),
1261 };
1262
1263 if !(url.starts_with("http://") || url.starts_with("https://")) {
1264 return format!("Error: url must start with http:// or https://: {url}");
1265 }
1266
1267 let client = match reqwest::blocking::Client::builder()
1268 .timeout(WEBSITE_READ_TIMEOUT)
1269 .user_agent(WEBSITE_USER_AGENT)
1270 .build()
1271 {
1272 Ok(client) => client,
1273 Err(err) => return format!("Error: failed to build website client: {err}"),
1274 };
1275
1276 let response = match client.get(url).send() {
1277 Ok(r) => r,
1278 Err(err) => return format!("Error: failed to fetch website: {err}"),
1279 };
1280
1281 let final_url = response.url().to_string();
1282 let status = response.status();
1283 if !status.is_success() {
1284 return format!("Error: website returned HTTP {status} for {final_url}");
1285 }
1286
1287 let body = match response.text() {
1288 Ok(text) => text,
1289 Err(err) => return format!("Error: failed to read website body: {err}"),
1290 };
1291
1292 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
1293 .unwrap()
1294 .captures(&body)
1295 .and_then(|captures| captures.get(1))
1296 .map(|m| {
1297 Regex::new(r"\s+")
1298 .unwrap()
1299 .replace_all(m.as_str(), " ")
1300 .trim()
1301 .to_string()
1302 })
1303 .filter(|title| !title.is_empty());
1304
1305 let with_block_breaks = Regex::new(
1306 r"(?is)</?(?:p|div|section|article|main|aside|header|footer|nav|li|ul|ol|h1|h2|h3|h4|h5|h6|br|tr|td|th)[^>]*>",
1307 )
1308 .unwrap()
1309 .replace_all(&body, "\n");
1310 let without_scripts = Regex::new(r"(?is)<script[^>]*>.*?</script>")
1311 .unwrap()
1312 .replace_all(&with_block_breaks, " ");
1313 let without_styles = Regex::new(r"(?is)<style[^>]*>.*?</style>")
1314 .unwrap()
1315 .replace_all(&without_scripts, " ");
1316 let without_tags = Regex::new(r"(?is)<[^>]+>")
1317 .unwrap()
1318 .replace_all(&without_styles, " ");
1319 let normalized_newlines = without_tags
1320 .replace("&nbsp;", " ")
1321 .replace("&amp;", "&")
1322 .replace("&lt;", "<")
1323 .replace("&gt;", ">")
1324 .replace("&quot;", "\"")
1325 .replace("&#39;", "'");
1326 let collapsed_lines = Regex::new(r"[ \t]+")
1327 .unwrap()
1328 .replace_all(&normalized_newlines, " ");
1329 let collapsed_breaks = Regex::new(r"\n\s*\n+")
1330 .unwrap()
1331 .replace_all(&collapsed_lines, "\n\n");
1332 let cleaned = collapsed_breaks
1333 .lines()
1334 .map(str::trim)
1335 .filter(|line| !line.is_empty())
1336 .collect::<Vec<_>>()
1337 .join("\n");
1338
1339 if cleaned.is_empty() {
1340 return format!("Fetched {url}, but no readable text content was found.");
1341 }
1342
1343 let mut metadata = vec![format!("URL: {final_url}")];
1344 if let Some(title) = &title {
1345 metadata.push(format!("Title: {title}"));
1346 }
1347
1348 let body_text = match title {
1349 Some(title) if !cleaned.starts_with(&title) => cleaned,
1350 _ => cleaned,
1351 };
1352
1353 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
1354
1355 if output.len() > WEBSITE_READ_CHAR_LIMIT {
1356 let truncated: String = output.chars().take(WEBSITE_READ_CHAR_LIMIT).collect();
1357 return format!(
1358 "{truncated}\n\n--- truncated (showing {WEBSITE_READ_CHAR_LIMIT} of {} characters) ---",
1359 output.len()
1360 );
1361 }
1362
1363 output
1364 }
1365
1366 // ── create_directory ─────────────────────────────────────────────────────────
1367
1368 fn exec_create_directory(arguments: &str) -> String {
1369 let args: Value = match serde_json::from_str(arguments) {
1370 Ok(v) => v,
1371 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1372 };
1373
1374 let path_str = match args.get("path").and_then(Value::as_str) {
1375 Some(p) => p,
1376 None => return "Error: missing required parameter \"path\"".to_string(),
1377 };
1378
1379 let path = Path::new(path_str);
1380 let absolute_path = absolute_path(path);
1381 let absolute_path_str = absolute_path.display().to_string();
1382
1383 if absolute_path.exists() {
1384 if absolute_path.is_dir() {
1385 return format!("Directory already exists: {absolute_path_str}");
1386 }
1387 return format!("Error: path exists and is not a directory: {absolute_path_str}");
1388 }
1389
1390 match fs::create_dir_all(&absolute_path) {
1391 Ok(()) => format!("Created directory: {absolute_path_str}"),
1392 Err(err) => format!("Error: could not create directory: {err}"),
1393 }
1394 }
1395
1396 /// fails if file exists so the LLM is forced to use `edit_file` for modifications.
1397 fn exec_create_file(arguments: &str) -> String {
1398 let args: Value = match serde_json::from_str(arguments) {
1399 Ok(v) => v,
1400 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1401 };
1402
1403 let path_str = match args.get("path").and_then(Value::as_str) {
1404 Some(p) => p,
1405 None => return "Error: missing required parameter \"path\"".to_string(),
1406 };
1407
1408 let content = match args.get("content").and_then(Value::as_str) {
1409 Some(c) => c,
1410 None => return "Error: missing required parameter \"content\"".to_string(),
1411 };
1412
1413 let path = Path::new(path_str);
1414 let absolute_path = absolute_path(path);
1415 let absolute_path_str = absolute_path.display().to_string();
1416
1417 if absolute_path.exists() {
1418 return format!(
1419 "Error: file already exists: {absolute_path_str} — use edit_file to modify existing files"
1420 );
1421 }
1422
1423 if let Some(parent) = absolute_path.parent()
1424 && !parent.as_os_str().is_empty()
1425 && !parent.exists()
1426 && let Err(err) = fs::create_dir_all(parent)
1427 {
1428 return format!("Error: could not create parent directories: {err}");
1429 }
1430
1431 match fs::write(&absolute_path, content) {
1432 Ok(()) => format!(
1433 "Created file: {absolute_path_str} ({} bytes)",
1434 content.len()
1435 ),
1436 Err(err) => format!("Error: could not write file: {err}"),
1437 }
1438 }
1439
1440 // ── edit_file / multi_edit ─────────────────────────────────────────────────
1441
1442 /// Apply one exact-substring replacement to `contents`. Returns the updated
1443 /// string, or a human-readable explanation of why the match failed so the model
1444 /// can correct itself in a single follow-up instead of guessing blindly.
1445 fn apply_edit(
1446 contents: &str,
1447 old_text: &str,
1448 new_text: &str,
1449 replace_all: bool,
1450 ) -> Result<String, String> {
1451 if old_text.is_empty() {
1452 return Err("old_text is empty; nothing to match".to_string());
1453 }
1454 if old_text == new_text {
1455 return Err("old_text and new_text are identical; no change to make".to_string());
1456 }
1457
1458 let occurrences = contents.matches(old_text).count();
1459
1460 if occurrences == 0 {
1461 return Err(format!(
1462 "old_text not found. Use read_file to copy the exact text \
1463 (including whitespace and indentation) to replace.{}",
1464 nearest_line_hint(contents, old_text)
1465 ));
1466 }
1467
1468 if occurrences > 1 && !replace_all {
1469 return Err(format!(
1470 "old_text appears {occurrences} times; include more surrounding context so it \
1471 matches exactly once, or set replace_all to true to change every occurrence."
1472 ));
1473 }
1474
1475 if replace_all {
1476 Ok(contents.replace(old_text, new_text))
1477 } else {
1478 Ok(contents.replacen(old_text, new_text, 1))
1479 }
1480 }
1481
1482 /// When `old_text` doesn't match verbatim, point at the line whose trimmed text
1483 /// equals the first trimmed line of `old_text` — the usual culprit is a
1484 /// whitespace/indentation mismatch, and naming the line lets the model fix it.
1485 fn nearest_line_hint(contents: &str, old_text: &str) -> String {
1486 let first = old_text.lines().find(|l| !l.trim().is_empty());
1487 let Some(first) = first.map(str::trim) else {
1488 return String::new();
1489 };
1490 for (idx, line) in contents.lines().enumerate() {
1491 if line.trim() == first {
1492 return format!(
1493 " (the first line of old_text appears at line {}, so the difference is likely \
1494 whitespace or indentation)",
1495 idx + 1
1496 );
1497 }
1498 }
1499 String::new()
1500 }
1501
1502 fn exec_edit_file(arguments: &str) -> String {
1503 let args: Value = match serde_json::from_str(arguments) {
1504 Ok(v) => v,
1505 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1506 };
1507
1508 let path_str = match args.get("path").and_then(Value::as_str) {
1509 Some(p) => p,
1510 None => return "Error: missing required parameter \"path\"".to_string(),
1511 };
1512
1513 let old_text = match args.get("old_text").and_then(Value::as_str) {
1514 Some(t) => t,
1515 None => return "Error: missing required parameter \"old_text\"".to_string(),
1516 };
1517
1518 let new_text = match args.get("new_text").and_then(Value::as_str) {
1519 Some(t) => t,
1520 None => return "Error: missing required parameter \"new_text\"".to_string(),
1521 };
1522
1523 let replace_all = args
1524 .get("replace_all")
1525 .and_then(Value::as_bool)
1526 .unwrap_or(false);
1527
1528 let path = Path::new(path_str);
1529 let absolute_path = absolute_path(path);
1530 let absolute_path_str = absolute_path.display().to_string();
1531
1532 if !absolute_path.exists() {
1533 return format!(
1534 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1535 );
1536 }
1537
1538 if !absolute_path.is_file() {
1539 return format!("Error: path is not a file: {absolute_path_str}");
1540 }
1541
1542 let contents = match fs::read_to_string(&absolute_path) {
1543 Ok(c) => c,
1544 Err(err) => return format!("Error: could not read file: {err}"),
1545 };
1546
1547 let updated = match apply_edit(&contents, old_text, new_text, replace_all) {
1548 Ok(updated) => updated,
1549 Err(why) => return format!("Error: {why} (in {absolute_path_str})"),
1550 };
1551
1552 match fs::write(&absolute_path, &updated) {
1553 Ok(()) => format!(
1554 "Edited file: {absolute_path_str} ({} bytes written)",
1555 updated.len()
1556 ),
1557 Err(err) => format!("Error: could not write file: {err}"),
1558 }
1559 }
1560
1561 /// Apply a batch of edits to one file atomically: each edit is applied to the
1562 /// result of the previous one, and the file is only written if *every* edit
1563 /// matches. A failure leaves the file untouched.
1564 fn exec_multi_edit(arguments: &str) -> String {
1565 let args: Value = match serde_json::from_str(arguments) {
1566 Ok(v) => v,
1567 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1568 };
1569
1570 let path_str = match args.get("path").and_then(Value::as_str) {
1571 Some(p) => p,
1572 None => return "Error: missing required parameter \"path\"".to_string(),
1573 };
1574
1575 let edits = match args.get("edits").and_then(Value::as_array) {
1576 Some(e) if !e.is_empty() => e,
1577 Some(_) => return "Error: \"edits\" must contain at least one edit".to_string(),
1578 None => return "Error: missing required parameter \"edits\"".to_string(),
1579 };
1580
1581 let path = Path::new(path_str);
1582 let absolute_path = absolute_path(path);
1583 let absolute_path_str = absolute_path.display().to_string();
1584
1585 if !absolute_path.exists() {
1586 return format!(
1587 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1588 );
1589 }
1590
1591 if !absolute_path.is_file() {
1592 return format!("Error: path is not a file: {absolute_path_str}");
1593 }
1594
1595 let mut working = match fs::read_to_string(&absolute_path) {
1596 Ok(c) => c,
1597 Err(err) => return format!("Error: could not read file: {err}"),
1598 };
1599
1600 for (idx, edit) in edits.iter().enumerate() {
1601 let old_text = match edit.get("old_text").and_then(Value::as_str) {
1602 Some(t) => t,
1603 None => return format!("Error: edit #{} is missing \"old_text\"", idx + 1),
1604 };
1605 let new_text = match edit.get("new_text").and_then(Value::as_str) {
1606 Some(t) => t,
1607 None => return format!("Error: edit #{} is missing \"new_text\"", idx + 1),
1608 };
1609 let replace_all = edit
1610 .get("replace_all")
1611 .and_then(Value::as_bool)
1612 .unwrap_or(false);
1613
1614 match apply_edit(&working, old_text, new_text, replace_all) {
1615 Ok(updated) => working = updated,
1616 Err(why) => {
1617 return format!(
1618 "Error: edit #{} failed: {why}. No changes were written to {absolute_path_str}.",
1619 idx + 1
1620 );
1621 }
1622 }
1623 }
1624
1625 match fs::write(&absolute_path, &working) {
1626 Ok(()) => format!(
1627 "Applied {} edits to {absolute_path_str} ({} bytes written)",
1628 edits.len(),
1629 working.len()
1630 ),
1631 Err(err) => format!("Error: could not write file: {err}"),
1632 }
1633 }
1634
1635 // ── glob ─────────────────────────────────────────────────────────────────────
1636
1637 /// Translate a shell-style glob into an anchored regex. Supports `*`
1638 /// (non-separator run), `**` (any number of directories), `?` (one
1639 /// non-separator), and `{a,b}` alternation; everything else is matched
1640 /// literally. Used by the `glob` tool (against relative paths), by
1641 /// `search_files`' `file_glob` filter (against bare file names), and by
1642 /// `crate::permissions` rule patterns (which re-anchor the result).
1643 pub(crate) fn glob_to_regex(glob: &str) -> String {
1644 let chars: Vec<char> = glob.chars().collect();
1645 let mut re = String::from("^");
1646 let mut brace_depth = 0usize;
1647 let mut i = 0;
1648
1649 while i < chars.len() {
1650 let c = chars[i];
1651 match c {
1652 '*' => {
1653 if i + 1 < chars.len() && chars[i + 1] == '*' {
1654 i += 1; // consume the second '*'
1655 if i + 1 < chars.len() && chars[i + 1] == '/' {
1656 // `**/` matches zero or more leading directories.
1657 re.push_str("(?:.*/)?");
1658 i += 1; // consume the '/'
1659 } else {
1660 re.push_str(".*");
1661 }
1662 } else {
1663 re.push_str("[^/]*");
1664 }
1665 }
1666 '?' => re.push_str("[^/]"),
1667 '{' => {
1668 brace_depth += 1;
1669 re.push_str("(?:");
1670 }
1671 '}' if brace_depth > 0 => {
1672 brace_depth -= 1;
1673 re.push(')');
1674 }
1675 ',' if brace_depth > 0 => re.push('|'),
1676 // Escape regex metacharacters so they match literally. (`{` is always
1677 // consumed by the brace arm above; an unmatched `}` lands here.)
1678 '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => {
1679 re.push('\\');
1680 re.push(c);
1681 }
1682 other => re.push(other),
1683 }
1684 i += 1;
1685 }
1686
1687 re.push('$');
1688 re
1689 }
1690
1691 fn exec_glob(arguments: &str) -> String {
1692 let args: Value = match serde_json::from_str(arguments) {
1693 Ok(v) => v,
1694 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1695 };
1696
1697 let pattern = match args.get("pattern").and_then(Value::as_str) {
1698 Some(p) => p,
1699 None => return "Error: missing required parameter \"pattern\"".to_string(),
1700 };
1701
1702 let re = match Regex::new(&glob_to_regex(pattern)) {
1703 Ok(r) => r,
1704 Err(err) => return format!("Error: invalid glob pattern: {err}"),
1705 };
1706
1707 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1708 let absolute_root = absolute_path(Path::new(root_str));
1709 let absolute_root_str = absolute_root.display().to_string();
1710
1711 if !absolute_root.exists() {
1712 return format!("Error: path does not exist: {absolute_root_str}");
1713 }
1714 if !absolute_root.is_dir() {
1715 return format!("Error: path is not a directory: {absolute_root_str}");
1716 }
1717
1718 let mut found: Vec<(std::time::SystemTime, String)> = Vec::new();
1719 glob_walk(&absolute_root, &absolute_root, &re, &mut found);
1720
1721 if found.is_empty() {
1722 return format!("No files match glob: {pattern}");
1723 }
1724
1725 // Most-recently-modified first.
1726 found.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
1727
1728 let total = found.len();
1729 let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect();
1730 if total > SEARCH_RESULTS_HARD_CAP {
1731 paths.truncate(SEARCH_RESULTS_HARD_CAP);
1732 paths.push(format!(
1733 "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---"
1734 ));
1735 }
1736
1737 paths.join("\n")
1738 }
1739
1740 fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) {
1741 if out.len() > SEARCH_RESULTS_HARD_CAP {
1742 return;
1743 }
1744
1745 let entries = match fs::read_dir(dir) {
1746 Ok(rd) => rd,
1747 Err(_) => return,
1748 };
1749
1750 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1751 sorted.sort_by_key(|e| e.file_name());
1752
1753 for entry in sorted {
1754 if out.len() > SEARCH_RESULTS_HARD_CAP {
1755 return;
1756 }
1757
1758 let path = entry.path();
1759 let name = entry.file_name();
1760 if name.to_string_lossy().starts_with('.') {
1761 continue;
1762 }
1763
1764 if path.is_dir() {
1765 glob_walk(root, &path, re, out);
1766 } else if path.is_file() {
1767 let relative = path
1768 .strip_prefix(root)
1769 .unwrap_or(&path)
1770 .to_string_lossy()
1771 .replace('\\', "/");
1772 if re.is_match(&relative) {
1773 let mtime = entry
1774 .metadata()
1775 .and_then(|m| m.modified())
1776 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1777 out.push((mtime, absolute_path_string(&path)));
1778 }
1779 }
1780 }
1781 }
1782
1783 // ── write_todos ──────────────────────────────────────────────────────────────
1784
1785 /// Renders the model's task checklist back as the tool result so the surface
1786 /// (TUI / ACP client) can show live progress. Pure presentation — the list is
1787 /// owned by the model, not persisted here.
1788 fn exec_write_todos(arguments: &str) -> String {
1789 let args: Value = match serde_json::from_str(arguments) {
1790 Ok(v) => v,
1791 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1792 };
1793
1794 let todos = match args.get("todos").and_then(Value::as_array) {
1795 Some(t) if !t.is_empty() => t,
1796 Some(_) => return "Error: \"todos\" must contain at least one item".to_string(),
1797 None => return "Error: missing required parameter \"todos\"".to_string(),
1798 };
1799
1800 let mut lines = Vec::with_capacity(todos.len());
1801 let mut completed = 0usize;
1802
1803 for (idx, todo) in todos.iter().enumerate() {
1804 let content = match todo.get("content").and_then(Value::as_str) {
1805 Some(c) => c.trim(),
1806 None => return format!("Error: todo #{} is missing \"content\"", idx + 1),
1807 };
1808 let status = todo
1809 .get("status")
1810 .and_then(Value::as_str)
1811 .unwrap_or("pending");
1812
1813 let marker = match status {
1814 "completed" => {
1815 completed += 1;
1816 "[x]"
1817 }
1818 "in_progress" => "[~]",
1819 _ => "[ ]",
1820 };
1821 lines.push(format!("{marker} {content}"));
1822 }
1823
1824 format!(
1825 "Task list updated ({completed}/{} done):\n{}",
1826 todos.len(),
1827 lines.join("\n")
1828 )
1829 }
1830
1831 // ── remember ─────────────────────────────────────────────────────────────────
1832
1833 /// Appends a durable note to the project's instruction file so it persists
1834 /// across sessions (the always-on counterpart to a one-off chat message).
1835 fn exec_remember(arguments: &str) -> String {
1836 let args: Value = match serde_json::from_str(arguments) {
1837 Ok(v) => v,
1838 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1839 };
1840
1841 let note = match args.get("note").and_then(Value::as_str) {
1842 Some(n) if !n.trim().is_empty() => n.trim(),
1843 Some(_) => return "Error: \"note\" must not be empty".to_string(),
1844 None => return "Error: missing required parameter \"note\"".to_string(),
1845 };
1846
1847 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1848 remember_at(&cwd, note)
1849 }
1850
1851 /// Core of `remember`, parameterized on the working directory so it can be
1852 /// tested without mutating the process-global current directory.
1853 fn remember_at(cwd: &Path, note: &str) -> String {
1854 let target = crate::instructions::memory_file(cwd);
1855 let target_str = target.display().to_string();
1856
1857 let existed = target.exists();
1858 let mut body = if existed {
1859 match fs::read_to_string(&target) {
1860 Ok(c) => c,
1861 Err(err) => return format!("Error: could not read {target_str}: {err}"),
1862 }
1863 } else {
1864 if let Some(parent) = target.parent()
1865 && !parent.as_os_str().is_empty()
1866 && !parent.exists()
1867 && let Err(err) = fs::create_dir_all(parent)
1868 {
1869 return format!("Error: could not create parent directories: {err}");
1870 }
1871 String::new()
1872 };
1873
1874 // Keep remembered notes grouped under one heading so the file stays tidy.
1875 const HEADING: &str = "## Remembered notes";
1876 if !body.contains(HEADING) {
1877 if !body.is_empty() && !body.ends_with('\n') {
1878 body.push('\n');
1879 }
1880 if !body.is_empty() {
1881 body.push('\n');
1882 }
1883 body.push_str(HEADING);
1884 body.push('\n');
1885 }
1886 if !body.ends_with('\n') {
1887 body.push('\n');
1888 }
1889 body.push_str("- ");
1890 body.push_str(note);
1891 body.push('\n');
1892
1893 match fs::write(&target, &body) {
1894 Ok(()) => {
1895 let verb = if existed { "Appended to" } else { "Created" };
1896 format!("{verb} {target_str}: remembered \"{note}\"")
1897 }
1898 Err(err) => format!("Error: could not write {target_str}: {err}"),
1899 }
1900 }
1901
1902 // ── delete_file ──────────────────────────────────────────────────────────────
1903
1904 /// only removes files or *empty* directories — no recursive deletes.
1905 fn exec_delete_file(arguments: &str) -> String {
1906 let args: Value = match serde_json::from_str(arguments) {
1907 Ok(v) => v,
1908 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1909 };
1910
1911 let path_str = match args.get("path").and_then(Value::as_str) {
1912 Some(p) => p,
1913 None => return "Error: missing required parameter \"path\"".to_string(),
1914 };
1915
1916 let path = Path::new(path_str);
1917 let absolute_path = absolute_path(path);
1918 let absolute_path_str = absolute_path.display().to_string();
1919
1920 if !absolute_path.exists() {
1921 return format!("Error: path does not exist: {absolute_path_str}");
1922 }
1923
1924 if absolute_path.is_dir() {
1925 match fs::remove_dir(&absolute_path) {
1926 Ok(()) => format!("Deleted empty directory: {absolute_path_str}"),
1927 Err(err) => format!(
1928 "Error: could not delete directory: {err}. \
1929 Only empty directories can be deleted."
1930 ),
1931 }
1932 } else {
1933 match fs::remove_file(&absolute_path) {
1934 Ok(()) => format!("Deleted file: {absolute_path_str}"),
1935 Err(err) => format!("Error: could not delete file: {err}"),
1936 }
1937 }
1938 }
1939
1940 // ── run_command ──────────────────────────────────────────────────────────────
1941
1942 const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1943 const COMMAND_OUTPUT_LIMIT: usize = 50_000;
1944 /// How long to keep waiting for the output drains once the command itself has
1945 /// exited. Only a process that outlived the shell and still holds a pipe can
1946 /// use this up, so it is short.
1947 const OUTPUT_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
1948
1949 /// Spawn `command_str` through the platform shell with piped stdout/stderr.
1950 /// Shared by the foreground and background paths of `run_command`.
1951 ///
1952 /// stdin is `/dev/null`, never inherited. In ACP mode sigit's stdin is the
1953 /// JSON-RPC pipe from the editor, and a child holding that fd can read the
1954 /// client's next request out of it: the agent never sees the request, the
1955 /// editor waits forever for a reply, and the session is wedged past any
1956 /// timeout this function's callers apply. Null also means a command that wants
1957 /// input gets EOF and fails instead of blocking on input nobody can type.
1958 ///
1959 /// On Unix the child also leads its own process group, so a timeout can kill
1960 /// the whole tree. `sh -c` may fork rather than exec (dash does), and a
1961 /// surviving grandchild keeps the pipe write ends open.
1962 fn spawn_shell(command_str: &str, cwd_path: &Path) -> std::io::Result<std::process::Child> {
1963 #[cfg(unix)]
1964 let (shell, flag) = ("sh", "-c");
1965 #[cfg(windows)]
1966 let (shell, flag) = ("cmd", "/C");
1967
1968 let mut command = Command::new(shell);
1969 command
1970 .arg(flag)
1971 .arg(command_str)
1972 .current_dir(cwd_path)
1973 .stdin(std::process::Stdio::null())
1974 .stdout(std::process::Stdio::piped())
1975 .stderr(std::process::Stdio::piped());
1976 #[cfg(unix)]
1977 std::os::unix::process::CommandExt::process_group(&mut command, 0);
1978 command.spawn()
1979 }
1980
1981 /// SIGKILL a shell spawned by [`spawn_shell`] and everything it started, then
1982 /// reap it so the timeout path doesn't leave a zombie behind.
1983 fn kill_shell_tree(child: &mut std::process::Child) {
1984 #[cfg(unix)]
1985 {
1986 // Negative pid is the process group; the child is its leader. Fall
1987 // back to the child alone if the group is already gone.
1988 if unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL) } != 0 {
1989 let _ = child.kill();
1990 }
1991 }
1992 #[cfg(windows)]
1993 let _ = child.kill();
1994 let _ = child.wait();
1995 }
1996
1997 /// Trailer identifying siGit Code as the co-author of commits it creates.
1998 /// GitHub detects `Co-authored-by:` trailers on the last lines of a commit
1999 /// message (separated from the body by a blank line) and lists the agent
2000 /// alongside the human author; `noreply@sigit.si`
2001 /// is the noreply address for the <https://github.com/sigitc> account
2002 /// ("siGit Code"), so the co-author is rendered with that account's avatar
2003 /// and profile link. The system prompt asks the model to add this itself;
2004 /// [`ensure_commit_co_author`] is the safety net when it forgets.
2005 pub const COMMIT_CO_AUTHOR_TRAILER: &str = "Co-Authored-By: siGit Code <noreply@sigit.si>";
2006
2007 /// Run `git <args>` in `cwd`, returning trimmed stdout on success.
2008 ///
2009 /// stdin is null for the same reason as [`spawn_shell`]: git can ask for input
2010 /// (a credential, a signing passphrase), and inheriting the ACP pipe would let
2011 /// it read the editor's next request instead.
2012 fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
2013 let output = Command::new("git")
2014 .args(args)
2015 .current_dir(cwd)
2016 .stdin(std::process::Stdio::null())
2017 .output()
2018 .ok()?;
2019 output
2020 .status
2021 .success()
2022 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
2023 }
2024
2025 fn git_head(cwd: &Path) -> Option<String> {
2026 git_stdout(cwd, &["rev-parse", "HEAD"])
2027 }
2028
2029 /// Deterministic co-author attribution: if the commit at HEAD lacks the
2030 /// siGit Code trailer, amend it in (via `git commit --amend --trailer`, which
2031 /// places it after a blank line — the format GitHub detects). Never rewrites
2032 /// a commit that is already on a remote. Returns a note describing the amend
2033 /// so the model and user can see it happened.
2034 fn ensure_commit_co_author(cwd: &Path) -> Option<String> {
2035 let message = git_stdout(cwd, &["log", "-1", "--format=%B"])?;
2036 if message
2037 .to_lowercase()
2038 .contains("co-authored-by: sigit code")
2039 {
2040 return None;
2041 }
2042 // Amending changes the commit id; a commit that any remote ref already
2043 // contains must be left alone or the branch diverges from its upstream.
2044 match git_stdout(cwd, &["branch", "-r", "--contains", "HEAD"]) {
2045 Some(remotes) if remotes.is_empty() => {}
2046 _ => return None,
2047 }
2048 let amend = Command::new("git")
2049 .args(["commit", "--amend", "--no-edit", "--trailer"])
2050 .arg(COMMIT_CO_AUTHOR_TRAILER)
2051 .stdin(std::process::Stdio::null())
2052 .current_dir(cwd)
2053 .output()
2054 .ok()?;
2055 if amend.status.success() {
2056 log::info!(
2057 "appended co-author trailer to the new commit in {}",
2058 cwd.display()
2059 );
2060 Some(format!(
2061 "[siGit Code] The new commit was amended to append the co-author trailer \
2062 \"{COMMIT_CO_AUTHOR_TRAILER}\" (its hash changed)."
2063 ))
2064 } else {
2065 log::warn!(
2066 "could not append co-author trailer in {}: {}",
2067 cwd.display(),
2068 String::from_utf8_lossy(&amend.stderr).trim()
2069 );
2070 None
2071 }
2072 }
2073
2074 /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT unless
2075 /// `run_in_background` is set, in which case the child is registered as a
2076 /// background task and polled with `command_output` / stopped with
2077 /// `kill_command`.
2078 fn exec_run_command(arguments: &str) -> String {
2079 let args: Value = match serde_json::from_str(arguments) {
2080 Ok(v) => v,
2081 Err(err) => return format!("Error: failed to parse arguments: {err}"),
2082 };
2083
2084 let command_str = match args.get("command").and_then(Value::as_str) {
2085 Some(c) => c,
2086 None => return "Error: missing required parameter \"command\"".to_string(),
2087 };
2088
2089 let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
2090 let cwd = args
2091 .get("cwd")
2092 .and_then(Value::as_str)
2093 .unwrap_or(&default_cwd);
2094 let cwd_path = absolute_path(Path::new(cwd));
2095 let cwd_str = cwd_path.display().to_string();
2096
2097 if !cwd_path.exists() {
2098 return format!("Error: working directory does not exist: {cwd_str}");
2099 }
2100
2101 let run_in_background = args
2102 .get("run_in_background")
2103 .and_then(Value::as_bool)
2104 .unwrap_or(false);
2105
2106 log::info!("run_command: `{command_str}` in `{cwd_str}` (background: {run_in_background})");
2107
2108 if run_in_background {
2109 return start_background_task(command_str, &cwd_path);
2110 }
2111
2112 // Co-author attribution: note where HEAD is before a command that looks
2113 // like it may commit, so a new commit can be detected afterwards. The
2114 // string check is only a cheap trigger — a false positive costs one
2115 // `git rev-parse` and nothing else. Background tasks skip the gate: their
2116 // commits finish after the tool returns, when there is nothing to amend
2117 // from.
2118 let may_commit = command_str.contains("git") && command_str.contains("commit");
2119 let head_before = if may_commit {
2120 git_head(&cwd_path)
2121 } else {
2122 None
2123 };
2124
2125 let mut child = match spawn_shell(command_str, &cwd_path) {
2126 Ok(c) => c,
2127 Err(err) => return format!("Error: failed to spawn command: {err}"),
2128 };
2129
2130 // Drain both pipes on their own threads. Waiting first and reading after
2131 // deadlocks the moment a command writes more than the pipe buffer holds
2132 // (64 KiB on Linux, less on macOS): the child blocks on the full pipe, we
2133 // block on the child, and the only way out is the timeout. A build or a
2134 // verbose test run crosses that on its own.
2135 let (chunks_tx, chunks_rx) = std::sync::mpsc::channel::<(u8, Vec<u8>)>();
2136 let drain = |tag: u8, pipe: Option<Box<dyn std::io::Read + Send>>| {
2137 let tx = chunks_tx.clone();
2138 std::thread::spawn(move || {
2139 let mut buf = Vec::new();
2140 if let Some(mut pipe) = pipe {
2141 let _ = pipe.read_to_end(&mut buf);
2142 }
2143 let _ = tx.send((tag, buf));
2144 });
2145 };
2146 drain(0, child.stdout.take().map(|pipe| Box::new(pipe) as _));
2147 drain(1, child.stderr.take().map(|pipe| Box::new(pipe) as _));
2148 drop(chunks_tx);
2149
2150 let start = std::time::Instant::now();
2151 let status = loop {
2152 match child.try_wait() {
2153 Ok(Some(status)) => break status,
2154 Ok(None) => {
2155 if start.elapsed() >= COMMAND_TIMEOUT {
2156 kill_shell_tree(&mut child);
2157 return format!(
2158 "Error: command timed out after {} seconds and was killed.",
2159 COMMAND_TIMEOUT.as_secs()
2160 );
2161 }
2162 std::thread::sleep(std::time::Duration::from_millis(100));
2163 }
2164 Err(err) => return format!("Error: failed to wait on command: {err}"),
2165 }
2166 };
2167
2168 // Collect what the drains read, bounded. The shell has exited, but a
2169 // grandchild that outlived it can still hold a pipe open, and joining
2170 // those threads would wait on it forever.
2171 let mut stdout_bytes = Vec::new();
2172 let mut stderr_bytes = Vec::new();
2173 let deadline = std::time::Instant::now() + OUTPUT_GRACE;
2174 for _ in 0..2 {
2175 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
2176 match chunks_rx.recv_timeout(remaining) {
2177 Ok((0, bytes)) => stdout_bytes = bytes,
2178 Ok((_, bytes)) => stderr_bytes = bytes,
2179 Err(_) => break,
2180 }
2181 }
2182
2183 let exit_code = status.code().unwrap_or(-1);
2184 let mut combined = String::new();
2185 combined.push_str(&String::from_utf8_lossy(&stdout_bytes));
2186 combined.push_str(&String::from_utf8_lossy(&stderr_bytes));
2187
2188 // A new commit appeared under this command: make sure it carries the
2189 // siGit co-author trailer (see `ensure_commit_co_author`).
2190 if may_commit {
2191 let head_after = git_head(&cwd_path);
2192 if head_after.is_some()
2193 && head_after != head_before
2194 && let Some(note) = ensure_commit_co_author(&cwd_path)
2195 {
2196 if !combined.is_empty() && !combined.ends_with('\n') {
2197 combined.push('\n');
2198 }
2199 combined.push_str(&note);
2200 }
2201 }
2202
2203 let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
2204 let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
2205 format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
2206 } else {
2207 combined
2208 };
2209
2210 if status.success() {
2211 if truncated.is_empty() {
2212 format!("Command succeeded (exit code {exit_code}) with no output.")
2213 } else {
2214 format!("Exit code {exit_code}:\n{truncated}")
2215 }
2216 } else {
2217 format!("Command failed (exit code {exit_code}):\n{truncated}")
2218 }
2219 }
2220
2221 // ── background tasks (command_output / kill_command) ─────────────────────────
2222
2223 /// A command started with `run_in_background`. The child is never detached,
2224 /// so background tasks die with the sigit process.
2225 struct BackgroundTask {
2226 command: String,
2227 child: std::process::Child,
2228 /// Combined stdout+stderr drained by the reader threads, shared with them.
2229 output: Arc<Mutex<TaskOutput>>,
2230 /// Exit code cached once observed; `-1` when there is no code (killed by
2231 /// a signal). `None` while the task is still running.
2232 exit_code: Option<i32>,
2233 /// Set when the task was stopped via `kill_command`.
2234 killed: bool,
2235 }
2236
2237 /// Output accumulated for a background task since the last poll.
2238 #[derive(Default)]
2239 struct TaskOutput {
2240 buf: String,
2241 /// Whether the oldest output was dropped because `buf` hit the cap.
2242 dropped: bool,
2243 }
2244
2245 /// Process-global background task table (same pattern as `mcp::MCP`).
2246 fn tasks() -> &'static Mutex<HashMap<u64, BackgroundTask>> {
2247 static TASKS: OnceLock<Mutex<HashMap<u64, BackgroundTask>>> = OnceLock::new();
2248 TASKS.get_or_init(|| Mutex::new(HashMap::new()))
2249 }
2250
2251 fn lock_tasks() -> std::sync::MutexGuard<'static, HashMap<u64, BackgroundTask>> {
2252 tasks()
2253 .lock()
2254 .unwrap_or_else(|poisoned| poisoned.into_inner())
2255 }
2256
2257 fn next_task_id() -> u64 {
2258 static NEXT_ID: AtomicU64 = AtomicU64::new(1);
2259 NEXT_ID.fetch_add(1, Ordering::Relaxed)
2260 }
2261
2262 /// Drain a child's stream into the task's shared buffer on a plain std thread,
2263 /// capping the buffer at COMMAND_OUTPUT_LIMIT by dropping the oldest output.
2264 fn spawn_output_reader<R: std::io::Read + Send + 'static>(
2265 mut stream: R,
2266 output: Arc<Mutex<TaskOutput>>,
2267 ) {
2268 std::thread::spawn(move || {
2269 let mut chunk = [0u8; 8192];
2270 loop {
2271 match stream.read(&mut chunk) {
2272 Ok(0) | Err(_) => break,
2273 Ok(n) => {
2274 let text = String::from_utf8_lossy(&chunk[..n]).into_owned();
2275 let mut out = output
2276 .lock()
2277 .unwrap_or_else(|poisoned| poisoned.into_inner());
2278 out.buf.push_str(&text);
2279 if out.buf.len() > COMMAND_OUTPUT_LIMIT {
2280 let mut cut = out.buf.len() - COMMAND_OUTPUT_LIMIT;
2281 while !out.buf.is_char_boundary(cut) {
2282 cut += 1;
2283 }
2284 out.buf.drain(..cut);
2285 out.dropped = true;
2286 }
2287 }
2288 }
2289 }
2290 });
2291 }
2292
2293 /// Background branch of `run_command`: spawn, register, return immediately.
2294 fn start_background_task(command_str: &str, cwd_path: &Path) -> String {
2295 let mut child = match spawn_shell(command_str, cwd_path) {
2296 Ok(c) => c,
2297 Err(err) => return format!("Error: failed to spawn command: {err}"),
2298 };
2299
2300 let output = Arc::new(Mutex::new(TaskOutput::default()));
2301 if let Some(stdout) = child.stdout.take() {
2302 spawn_output_reader(stdout, Arc::clone(&output));
2303 }
2304 if let Some(stderr) = child.stderr.take() {
2305 spawn_output_reader(stderr, Arc::clone(&output));
2306 }
2307
2308 let task_id = next_task_id();
2309 lock_tasks().insert(
2310 task_id,
2311 BackgroundTask {
2312 command: command_str.to_string(),
2313 child,
2314 output,
2315 exit_code: None,
2316 killed: false,
2317 },
2318 );
2319
2320 format!(
2321 "Started background task {task_id}: `{command_str}`. Poll its output and status \
2322 with command_output (task_id: {task_id}); stop it with kill_command. The task is \
2323 killed when sigit exits."
2324 )
2325 }
2326
2327 /// Check (and cache) whether a task's child has exited. Returns the exit code,
2328 /// or `None` while it is still running.
2329 fn poll_exit_code(task: &mut BackgroundTask) -> Option<i32> {
2330 if task.exit_code.is_none()
2331 && let Ok(Some(status)) = task.child.try_wait()
2332 {
2333 task.exit_code = Some(status.code().unwrap_or(-1));
2334 }
2335 task.exit_code
2336 }
2337
2338 /// Take everything the task has printed since the last poll, plus whether
2339 /// older output was dropped at the buffer cap.
2340 fn drain_task_output(task: &BackgroundTask) -> (String, bool) {
2341 let mut out = task
2342 .output
2343 .lock()
2344 .unwrap_or_else(|poisoned| poisoned.into_inner());
2345 (
2346 std::mem::take(&mut out.buf),
2347 std::mem::take(&mut out.dropped),
2348 )
2349 }
2350
2351 fn parse_task_id(arguments: &str) -> Result<u64, String> {
2352 let args: Value = serde_json::from_str(arguments)
2353 .map_err(|err| format!("Error: failed to parse arguments: {err}"))?;
2354 args.get("task_id")
2355 .and_then(Value::as_u64)
2356 .ok_or_else(|| "Error: missing required parameter \"task_id\"".to_string())
2357 }
2358
2359 fn unknown_task(task_id: u64) -> String {
2360 format!(
2361 "Error: no background task with id {task_id}. Start one with run_command and \
2362 run_in_background set to true."
2363 )
2364 }
2365
2366 fn dropped_note(dropped: bool) -> &'static str {
2367 if dropped {
2368 "\n(note: earlier output was dropped after exceeding the 50000-byte buffer)"
2369 } else {
2370 ""
2371 }
2372 }
2373
2374 /// `command_output` tool: output since the last poll + running/exited status.
2375 fn exec_command_output(arguments: &str) -> String {
2376 let task_id = match parse_task_id(arguments) {
2377 Ok(id) => id,
2378 Err(err) => return err,
2379 };
2380
2381 let mut map = lock_tasks();
2382 let Some(task) = map.get_mut(&task_id) else {
2383 return unknown_task(task_id);
2384 };
2385
2386 let was_running = task.exit_code.is_none();
2387 let exit_code = poll_exit_code(task);
2388 if was_running && exit_code.is_some() {
2389 // The child just exited; give the reader threads a moment to flush
2390 // the final output through the pipes before draining the buffer.
2391 std::thread::sleep(std::time::Duration::from_millis(100));
2392 }
2393 let (new_output, dropped) = drain_task_output(task);
2394
2395 let command = &task.command;
2396 let status = match exit_code {
2397 None => format!("Task {task_id} (`{command}`) is still running."),
2398 Some(_) if task.killed => format!("Task {task_id} (`{command}`) was killed."),
2399 Some(code) => format!("Task {task_id} (`{command}`) exited with code {code}."),
2400 };
2401
2402 if new_output.is_empty() {
2403 format!(
2404 "{status} No new output since the last check.{}",
2405 dropped_note(dropped)
2406 )
2407 } else {
2408 format!(
2409 "{status} New output since the last check:{}\n{new_output}",
2410 dropped_note(dropped)
2411 )
2412 }
2413 }
2414
2415 /// `kill_command` tool: stop a background task and report its output tail.
2416 fn exec_kill_command(arguments: &str) -> String {
2417 let task_id = match parse_task_id(arguments) {
2418 Ok(id) => id,
2419 Err(err) => return err,
2420 };
2421
2422 let mut map = lock_tasks();
2423 let Some(task) = map.get_mut(&task_id) else {
2424 return unknown_task(task_id);
2425 };
2426
2427 if let Some(code) = poll_exit_code(task) {
2428 let (new_output, dropped) = drain_task_output(task);
2429 let command = &task.command;
2430 let tail = if new_output.is_empty() {
2431 String::new()
2432 } else {
2433 format!(" Unread output:\n{new_output}")
2434 };
2435 return format!(
2436 "Task {task_id} (`{command}`) had already exited with code {code}; nothing to \
2437 kill.{}{tail}",
2438 dropped_note(dropped)
2439 );
2440 }
2441
2442 if let Err(err) = task.child.kill() {
2443 return format!("Error: failed to kill task {task_id}: {err}");
2444 }
2445 match task.child.wait() {
2446 Ok(status) => task.exit_code = Some(status.code().unwrap_or(-1)),
2447 Err(_) => task.exit_code = Some(-1),
2448 }
2449 task.killed = true;
2450
2451 // Let the reader threads flush whatever was in flight before reporting.
2452 std::thread::sleep(std::time::Duration::from_millis(100));
2453 let (new_output, dropped) = drain_task_output(task);
2454
2455 let command = &task.command;
2456 let tail = if new_output.is_empty() {
2457 " It produced no unread output.".to_string()
2458 } else {
2459 format!(" Last output:\n{new_output}")
2460 };
2461 format!(
2462 "Killed task {task_id} (`{command}`).{}{tail}",
2463 dropped_note(dropped)
2464 )
2465 }
2466
2467 #[cfg(test)]
2468 mod tests {
2469 use super::*;
2470 use std::fs;
2471
2472 #[tokio::test]
2473 async fn test_execute_unknown_tool() {
2474 let result = execute_tool("nonexistent", "{}").await;
2475 assert!(result.starts_with("Unknown tool:"));
2476 }
2477
2478 #[test]
2479 fn test_read_file_missing_path_param() {
2480 let result = exec_read_file("{}");
2481 assert!(result.contains("missing required parameter"));
2482 }
2483
2484 #[test]
2485 fn test_read_file_nonexistent() {
2486 let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#);
2487 assert!(result.contains("does not exist"));
2488 }
2489
2490 #[test]
2491 fn test_read_file_success() {
2492 let dir = std::env::temp_dir().join("sigit_test_read_file");
2493 let _ = fs::create_dir_all(&dir);
2494 let file_path = dir.join("hello.txt");
2495 fs::write(&file_path, "hello world").unwrap();
2496
2497 let args = serde_json::json!({ "path": file_path }).to_string();
2498 let result = exec_read_file(&args);
2499 assert_eq!(result, "hello world");
2500
2501 let _ = fs::remove_dir_all(&dir);
2502 }
2503
2504 #[test]
2505 fn test_list_directory_missing_path_param() {
2506 let result = exec_list_directory("{}");
2507 assert!(result.contains("missing required parameter"));
2508 }
2509
2510 #[test]
2511 fn test_list_directory_success() {
2512 let dir = std::env::temp_dir().join("sigit_test_list_dir");
2513 let _ = fs::remove_dir_all(&dir);
2514 fs::create_dir_all(dir.join("subdir")).unwrap();
2515 fs::write(dir.join("aaa.txt"), "").unwrap();
2516 fs::write(dir.join("bbb.rs"), "").unwrap();
2517
2518 let args = serde_json::json!({ "path": dir }).to_string();
2519 let result = exec_list_directory(&args);
2520
2521 assert!(result.contains("[DIR] subdir"));
2522 assert!(result.contains("[FILE] aaa.txt"));
2523 assert!(result.contains("[FILE] bbb.rs"));
2524
2525 // Directories should appear before files.
2526 let dir_pos = result.find("[DIR]").unwrap();
2527 let file_pos = result.find("[FILE]").unwrap();
2528 assert!(dir_pos < file_pos);
2529
2530 let _ = fs::remove_dir_all(&dir);
2531 }
2532
2533 #[test]
2534 fn test_search_files_invalid_regex() {
2535 let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#);
2536 assert!(result.contains("invalid regex"));
2537 }
2538
2539 #[test]
2540 fn test_search_files_success() {
2541 let dir = std::env::temp_dir().join("sigit_test_search");
2542 let _ = fs::remove_dir_all(&dir);
2543 fs::create_dir_all(&dir).unwrap();
2544 fs::write(
2545 dir.join("code.rs"),
2546 "fn main() {\n println!(\"hello\");\n}\n",
2547 )
2548 .unwrap();
2549 fs::write(dir.join("other.txt"), "no match here\n").unwrap();
2550
2551 let args = serde_json::json!({
2552 "pattern": "println",
2553 "path": dir
2554 })
2555 .to_string();
2556 let result = exec_search_files(&args);
2557
2558 assert!(result.contains("code.rs:2:"));
2559 assert!(result.contains("println"));
2560 assert!(!result.contains("other.txt"));
2561
2562 let _ = fs::remove_dir_all(&dir);
2563 }
2564
2565 #[test]
2566 fn test_search_files_no_matches() {
2567 let dir = std::env::temp_dir().join("sigit_test_search_none");
2568 let _ = fs::remove_dir_all(&dir);
2569 fs::create_dir_all(&dir).unwrap();
2570 fs::write(dir.join("empty.txt"), "nothing special").unwrap();
2571
2572 let args = serde_json::json!({
2573 "pattern": "zzz_will_not_match_42",
2574 "path": dir
2575 })
2576 .to_string();
2577 let result = exec_search_files(&args);
2578 assert!(result.contains("No matches found"));
2579
2580 let _ = fs::remove_dir_all(&dir);
2581 }
2582
2583 #[test]
2584 fn test_all_tools_count() {
2585 let tools = all_tools();
2586 assert_eq!(tools.len(), 15);
2587 assert_eq!(tools[0].name, "read_file");
2588 assert_eq!(tools[1].name, "create_directory");
2589 assert_eq!(tools[2].name, "list_directory");
2590 assert_eq!(tools[3].name, "search_files");
2591 assert_eq!(tools[4].name, "read_website");
2592 assert_eq!(tools[5].name, "create_file");
2593 assert_eq!(tools[6].name, "edit_file");
2594 assert_eq!(tools[7].name, "delete_file");
2595 assert_eq!(tools[8].name, "run_command");
2596 assert_eq!(tools[9].name, "multi_edit");
2597 assert_eq!(tools[10].name, "glob");
2598 assert_eq!(tools[11].name, "write_todos");
2599 assert_eq!(tools[12].name, "remember");
2600 assert_eq!(tools[13].name, "command_output");
2601 assert_eq!(tools[14].name, "kill_command");
2602 }
2603
2604 #[test]
2605 fn test_edit_file_replace_all() {
2606 let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
2607 let _ = fs::remove_dir_all(&dir);
2608 fs::create_dir_all(&dir).unwrap();
2609 let file = dir.join("f.txt");
2610 fs::write(&file, "foo foo foo").unwrap();
2611
2612 // Without replace_all an ambiguous match is rejected.
2613 let args =
2614 serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
2615 let result = exec_edit_file(&args);
2616 assert!(result.contains("appears 3 times"), "{result}");
2617
2618 // With replace_all every occurrence is changed.
2619 let args = serde_json::json!({
2620 "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
2621 })
2622 .to_string();
2623 let result = exec_edit_file(&args);
2624 assert!(result.starts_with("Edited file:"), "{result}");
2625 assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
2626
2627 let _ = fs::remove_dir_all(&dir);
2628 }
2629
2630 #[test]
2631 fn test_edit_file_whitespace_hint() {
2632 let dir = std::env::temp_dir().join("sigit_test_edit_hint");
2633 let _ = fs::remove_dir_all(&dir);
2634 fs::create_dir_all(&dir).unwrap();
2635 let file = dir.join("f.txt");
2636 fs::write(&file, "line one\n indented\nline three\n").unwrap();
2637
2638 // old_text has more indentation than the file, so it isn't a substring,
2639 // but its trimmed content still locates the intended line.
2640 let args = serde_json::json!({
2641 "path": &file, "old_text": " indented", "new_text": "x"
2642 })
2643 .to_string();
2644 let result = exec_edit_file(&args);
2645 assert!(result.contains("line 2"), "{result}");
2646 assert!(result.contains("whitespace"), "{result}");
2647
2648 let _ = fs::remove_dir_all(&dir);
2649 }
2650
2651 #[test]
2652 fn test_multi_edit_atomic_on_failure() {
2653 let dir = std::env::temp_dir().join("sigit_test_multi_edit");
2654 let _ = fs::remove_dir_all(&dir);
2655 fs::create_dir_all(&dir).unwrap();
2656 let file = dir.join("f.txt");
2657 fs::write(&file, "alpha beta gamma").unwrap();
2658
2659 // Second edit can't match -> nothing should be written.
2660 let args = serde_json::json!({
2661 "path": &file,
2662 "edits": [
2663 { "old_text": "alpha", "new_text": "ALPHA" },
2664 { "old_text": "nope", "new_text": "x" }
2665 ]
2666 })
2667 .to_string();
2668 let result = exec_multi_edit(&args);
2669 assert!(result.contains("edit #2 failed"), "{result}");
2670 assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
2671
2672 // All-matching batch applies in sequence.
2673 let args = serde_json::json!({
2674 "path": &file,
2675 "edits": [
2676 { "old_text": "alpha", "new_text": "ALPHA" },
2677 { "old_text": "gamma", "new_text": "GAMMA" }
2678 ]
2679 })
2680 .to_string();
2681 let result = exec_multi_edit(&args);
2682 assert!(result.contains("Applied 2 edits"), "{result}");
2683 assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
2684
2685 let _ = fs::remove_dir_all(&dir);
2686 }
2687
2688 #[test]
2689 fn test_glob_to_regex() {
2690 let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
2691 assert!(re.is_match("src/tools.rs"));
2692 assert!(re.is_match("main.rs")); // `**/` matches zero directories too
2693 assert!(!re.is_match("src/tools.txt"));
2694
2695 let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
2696 assert!(re.is_match("app.ts"));
2697 assert!(re.is_match("app.tsx"));
2698 assert!(!re.is_match("app.js"));
2699 }
2700
2701 #[test]
2702 fn test_glob_tool_success() {
2703 let dir = std::env::temp_dir().join("sigit_test_glob");
2704 let _ = fs::remove_dir_all(&dir);
2705 fs::create_dir_all(dir.join("src")).unwrap();
2706 fs::write(dir.join("Cargo.toml"), "").unwrap();
2707 fs::write(dir.join("src/main.rs"), "").unwrap();
2708 fs::write(dir.join("src/lib.rs"), "").unwrap();
2709
2710 let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
2711 let result = exec_glob(&args);
2712 assert!(result.contains("main.rs"), "{result}");
2713 assert!(result.contains("lib.rs"), "{result}");
2714 assert!(!result.contains("Cargo.toml"), "{result}");
2715
2716 let _ = fs::remove_dir_all(&dir);
2717 }
2718
2719 #[test]
2720 fn test_search_files_file_glob_filter() {
2721 let dir = std::env::temp_dir().join("sigit_test_search_glob");
2722 let _ = fs::remove_dir_all(&dir);
2723 fs::create_dir_all(&dir).unwrap();
2724 fs::write(dir.join("code.rs"), "needle here\n").unwrap();
2725 fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
2726
2727 let args = serde_json::json!({
2728 "pattern": "needle", "path": &dir, "file_glob": "*.rs"
2729 })
2730 .to_string();
2731 let result = exec_search_files(&args);
2732 assert!(result.contains("code.rs"), "{result}");
2733 assert!(!result.contains("notes.txt"), "{result}");
2734
2735 let _ = fs::remove_dir_all(&dir);
2736 }
2737
2738 #[test]
2739 fn test_write_todos_renders_checklist() {
2740 let args = serde_json::json!({
2741 "todos": [
2742 { "content": "Read code", "status": "completed" },
2743 { "content": "Make change", "status": "in_progress" },
2744 { "content": "Run tests", "status": "pending" }
2745 ]
2746 })
2747 .to_string();
2748 let result = exec_write_todos(&args);
2749 assert!(result.contains("1/3 done"), "{result}");
2750 assert!(result.contains("[x] Read code"), "{result}");
2751 assert!(result.contains("[~] Make change"), "{result}");
2752 assert!(result.contains("[ ] Run tests"), "{result}");
2753 }
2754
2755 #[test]
2756 fn test_remember_appends_to_instruction_file() {
2757 let dir = std::env::temp_dir().join("sigit_test_remember");
2758 let _ = fs::remove_dir_all(&dir);
2759 fs::create_dir_all(dir.join(".git")).unwrap();
2760 let claude_md = dir.join("CLAUDE.md");
2761 fs::write(&claude_md, "# Project\n").unwrap();
2762
2763 let target = crate::instructions::memory_file(&dir);
2764 // Should pick the existing CLAUDE.md at the repo root.
2765 assert_eq!(
2766 target.canonicalize().unwrap(),
2767 claude_md.canonicalize().unwrap()
2768 );
2769
2770 let result = remember_at(&dir, "remembered text");
2771 assert!(result.contains("remembered"), "{result}");
2772
2773 let updated = fs::read_to_string(&claude_md).unwrap();
2774 assert!(updated.contains("## Remembered notes"), "{updated}");
2775 assert!(updated.contains("- remembered text"), "{updated}");
2776
2777 let _ = fs::remove_dir_all(&dir);
2778 }
2779
2780 #[test]
2781 fn test_all_tools_schemas_are_valid_json_objects() {
2782 for tool in all_tools() {
2783 assert!(
2784 tool.parameters_schema.is_object(),
2785 "schema for {} is not an object",
2786 tool.name
2787 );
2788 let obj = tool.parameters_schema.as_object().unwrap();
2789 assert!(obj.contains_key("type"));
2790 assert!(obj.contains_key("properties"));
2791 assert!(obj.contains_key("required"));
2792 }
2793 }
2794
2795 // ── read_website tests ───────────────────────────────────────────────
2796
2797 #[test]
2798 fn test_read_website_missing_url() {
2799 let result = exec_read_website("{}");
2800 assert!(result.contains("missing required parameter"));
2801 }
2802
2803 #[test]
2804 fn test_read_website_invalid_scheme() {
2805 let result = exec_read_website(r#"{"url": "file:///tmp/test.html"}"#);
2806 assert!(result.contains("url must start with http:// or https://"));
2807 }
2808
2809 #[test]
2810 fn test_read_website_extracts_title_from_html() {
2811 let body = r#"
2812 <html>
2813 <head>
2814 <title>Qwen 3.6 27B</title>
2815 </head>
2816 <body>
2817 <h1>Model card</h1>
2818 <p>Large language model.</p>
2819 </body>
2820 </html>
2821 "#;
2822
2823 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
2824 .unwrap()
2825 .captures(body)
2826 .and_then(|captures| captures.get(1))
2827 .map(|m| {
2828 Regex::new(r"\s+")
2829 .unwrap()
2830 .replace_all(m.as_str(), " ")
2831 .trim()
2832 .to_string()
2833 })
2834 .filter(|title| !title.is_empty());
2835
2836 assert_eq!(title.as_deref(), Some("Qwen 3.6 27B"));
2837 }
2838
2839 #[test]
2840 fn test_read_website_metadata_includes_final_url_header() {
2841 let final_url = "https://huggingface.co/Qwen/Qwen3.6-27B";
2842 let title = Some("Qwen 3.6 27B".to_string());
2843 let cleaned = "Model card\nLarge language model.".to_string();
2844
2845 let mut metadata = vec![format!("URL: {final_url}")];
2846 if let Some(title) = &title {
2847 metadata.push(format!("Title: {title}"));
2848 }
2849
2850 let body_text = match title {
2851 Some(_) => cleaned,
2852 None => cleaned,
2853 };
2854
2855 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
2856
2857 assert!(output.starts_with("URL: https://huggingface.co/Qwen/Qwen3.6-27B"));
2858 assert!(output.contains("\nTitle: Qwen 3.6 27B\n\n"));
2859 }
2860
2861 // ── create_directory tests ───────────────────────────────────────────
2862
2863 #[test]
2864 fn test_create_directory_missing_path() {
2865 let result = exec_create_directory("{}");
2866 assert!(result.contains("missing required parameter"));
2867 }
2868
2869 #[test]
2870 fn test_create_directory_success() {
2871 let dir = std::env::temp_dir()
2872 .join("sigit_test_create_directory")
2873 .join("nested")
2874 .join("child");
2875 let _ = fs::remove_dir_all(dir.parent().unwrap());
2876
2877 let args = serde_json::json!({ "path": dir }).to_string();
2878 let result = exec_create_directory(&args);
2879 assert!(result.starts_with("Created directory:"), "got: {result}");
2880 assert!(dir.exists());
2881 assert!(dir.is_dir());
2882
2883 let _ = fs::remove_dir_all(dir.parent().unwrap().parent().unwrap());
2884 }
2885
2886 #[test]
2887 fn test_create_directory_already_exists() {
2888 let dir = std::env::temp_dir().join("sigit_test_create_directory_exists");
2889 let _ = fs::remove_dir_all(&dir);
2890 fs::create_dir_all(&dir).unwrap();
2891
2892 let args = serde_json::json!({ "path": dir }).to_string();
2893 let result = exec_create_directory(&args);
2894 assert!(result.contains("Directory already exists"), "got: {result}");
2895
2896 let _ = fs::remove_dir_all(&dir);
2897 }
2898
2899 // ── create_file tests ────────────────────────────────────────────────
2900
2901 #[test]
2902 fn test_create_file_missing_path() {
2903 let result = exec_create_file(r#"{"content": "hello"}"#);
2904 assert!(result.contains("missing required parameter"));
2905 }
2906
2907 #[test]
2908 fn test_create_file_missing_content() {
2909 let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#);
2910 assert!(result.contains("missing required parameter"));
2911 }
2912
2913 #[test]
2914 fn test_create_file_success() {
2915 let dir = std::env::temp_dir().join("sigit_test_create_file");
2916 let _ = fs::remove_dir_all(&dir);
2917
2918 let file_path = dir.join("sub").join("new_file.txt");
2919 let args = serde_json::json!({
2920 "path": file_path,
2921 "content": "hello world"
2922 })
2923 .to_string();
2924
2925 let result = exec_create_file(&args);
2926 assert!(result.starts_with("Created file:"), "got: {result}");
2927 assert!(file_path.exists());
2928 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
2929
2930 let _ = fs::remove_dir_all(&dir);
2931 }
2932
2933 #[test]
2934 fn test_create_file_already_exists() {
2935 let dir = std::env::temp_dir().join("sigit_test_create_exists");
2936 let _ = fs::remove_dir_all(&dir);
2937 fs::create_dir_all(&dir).unwrap();
2938
2939 let file_path = dir.join("existing.txt");
2940 fs::write(&file_path, "original").unwrap();
2941
2942 let args = serde_json::json!({
2943 "path": file_path,
2944 "content": "overwrite attempt"
2945 })
2946 .to_string();
2947
2948 let result = exec_create_file(&args);
2949 assert!(result.contains("already exists"), "got: {result}");
2950 // Original content untouched.
2951 assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
2952
2953 let _ = fs::remove_dir_all(&dir);
2954 }
2955
2956 // ── edit_file tests ──────────────────────────────────────────────────
2957
2958 #[test]
2959 fn test_edit_file_missing_params() {
2960 let result = exec_edit_file(r#"{"path": "x"}"#);
2961 assert!(result.contains("missing required parameter"));
2962
2963 let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#);
2964 assert!(result.contains("missing required parameter"));
2965 }
2966
2967 #[test]
2968 fn test_edit_file_nonexistent() {
2969 let result = exec_edit_file(
2970 r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#,
2971 );
2972 assert!(result.contains("does not exist"));
2973 }
2974
2975 #[test]
2976 fn test_edit_file_success() {
2977 let dir = std::env::temp_dir().join("sigit_test_edit_file");
2978 let _ = fs::remove_dir_all(&dir);
2979 fs::create_dir_all(&dir).unwrap();
2980
2981 let file_path = dir.join("code.rs");
2982 fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
2983
2984 let args = serde_json::json!({
2985 "path": file_path,
2986 "old_text": "println!(\"hello\")",
2987 "new_text": "println!(\"world\")"
2988 })
2989 .to_string();
2990
2991 let result = exec_edit_file(&args);
2992 assert!(result.starts_with("Edited file:"), "got: {result}");
2993
2994 let updated = fs::read_to_string(&file_path).unwrap();
2995 assert!(updated.contains("println!(\"world\")"));
2996 assert!(!updated.contains("println!(\"hello\")"));
2997
2998 let _ = fs::remove_dir_all(&dir);
2999 }
3000
3001 #[test]
3002 fn test_edit_file_old_text_not_found() {
3003 let dir = std::env::temp_dir().join("sigit_test_edit_notfound");
3004 let _ = fs::remove_dir_all(&dir);
3005 fs::create_dir_all(&dir).unwrap();
3006
3007 let file_path = dir.join("data.txt");
3008 fs::write(&file_path, "aaa bbb ccc").unwrap();
3009
3010 let args = serde_json::json!({
3011 "path": file_path,
3012 "old_text": "zzz",
3013 "new_text": "yyy"
3014 })
3015 .to_string();
3016
3017 let result = exec_edit_file(&args);
3018 assert!(result.contains("old_text not found"), "got: {result}");
3019
3020 let _ = fs::remove_dir_all(&dir);
3021 }
3022
3023 #[test]
3024 fn test_edit_file_ambiguous_match() {
3025 let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous");
3026 let _ = fs::remove_dir_all(&dir);
3027 fs::create_dir_all(&dir).unwrap();
3028
3029 let file_path = dir.join("repeat.txt");
3030 fs::write(&file_path, "foo bar foo bar foo").unwrap();
3031
3032 let args = serde_json::json!({
3033 "path": file_path,
3034 "old_text": "foo",
3035 "new_text": "baz"
3036 })
3037 .to_string();
3038
3039 let result = exec_edit_file(&args);
3040 assert!(result.contains("appears 3 times"), "got: {result}");
3041 // File should be unchanged.
3042 assert_eq!(
3043 fs::read_to_string(&file_path).unwrap(),
3044 "foo bar foo bar foo"
3045 );
3046
3047 let _ = fs::remove_dir_all(&dir);
3048 }
3049
3050 // ── delete_file tests ────────────────────────────────────────────────
3051
3052 #[test]
3053 fn test_delete_file_missing_path() {
3054 let result = exec_delete_file("{}");
3055 assert!(
3056 result.contains("missing required parameter"),
3057 "got: {result}"
3058 );
3059 }
3060
3061 #[test]
3062 fn test_delete_file_nonexistent() {
3063 let result = exec_delete_file(r#"{"path": "/tmp/sigit_test_no_such_file_xyz"}"#);
3064 assert!(result.contains("does not exist"), "got: {result}");
3065 }
3066
3067 #[test]
3068 fn test_delete_file_success() {
3069 let dir = std::env::temp_dir().join("sigit_test_delete_file");
3070 let _ = fs::remove_dir_all(&dir);
3071 fs::create_dir_all(&dir).unwrap();
3072
3073 let file_path = dir.join("to_delete.txt");
3074 fs::write(&file_path, "bye").unwrap();
3075 assert!(file_path.exists());
3076
3077 let args = serde_json::json!({ "path": file_path }).to_string();
3078 let result = exec_delete_file(&args);
3079 assert!(result.contains("Deleted file"), "got: {result}");
3080 assert!(!file_path.exists());
3081
3082 let _ = fs::remove_dir_all(&dir);
3083 }
3084
3085 #[test]
3086 fn test_delete_empty_directory() {
3087 let dir = std::env::temp_dir().join("sigit_test_delete_empty_dir");
3088 let _ = fs::remove_dir_all(&dir);
3089 fs::create_dir_all(&dir).unwrap();
3090
3091 let args = serde_json::json!({ "path": dir }).to_string();
3092 let result = exec_delete_file(&args);
3093 assert!(result.contains("Deleted empty directory"), "got: {result}");
3094 assert!(!dir.exists());
3095 }
3096
3097 #[test]
3098 fn test_delete_nonempty_directory() {
3099 let dir = std::env::temp_dir().join("sigit_test_delete_nonempty_dir");
3100 let _ = fs::remove_dir_all(&dir);
3101 fs::create_dir_all(&dir).unwrap();
3102 fs::write(dir.join("child.txt"), "content").unwrap();
3103
3104 let args = serde_json::json!({ "path": dir }).to_string();
3105 let result = exec_delete_file(&args);
3106 assert!(result.contains("Error"), "got: {result}");
3107 assert!(dir.exists(), "directory should not have been deleted");
3108
3109 let _ = fs::remove_dir_all(&dir);
3110 }
3111
3112 // ── run_command tests ────────────────────────────────────────────────
3113
3114 #[test]
3115 fn test_run_command_missing_command() {
3116 let result = exec_run_command("{}");
3117 assert!(
3118 result.contains("missing required parameter"),
3119 "got: {result}"
3120 );
3121 }
3122
3123 #[test]
3124 fn test_run_command_success() {
3125 let result = exec_run_command(r#"{"command": "echo hello"}"#);
3126 assert!(result.contains("hello"), "got: {result}");
3127 assert!(result.contains("Exit code 0"), "got: {result}");
3128 }
3129
3130 /// Fresh git repo with one commit, test identity, and signing off (the
3131 /// developer's global gpgsign must not leak into sandbox commits).
3132 fn init_test_repo(name: &str) -> std::path::PathBuf {
3133 let dir = std::env::temp_dir().join(format!("sigit_test_{name}_{}", std::process::id()));
3134 let _ = fs::remove_dir_all(&dir);
3135 fs::create_dir_all(&dir).unwrap();
3136 test_git(&dir, &["init", "-q", "-b", "main"]);
3137 test_git(&dir, &["config", "user.name", "Test User"]);
3138 test_git(&dir, &["config", "user.email", "test@example.com"]);
3139 test_git(&dir, &["config", "commit.gpgsign", "false"]);
3140 fs::write(dir.join("file.txt"), "one\n").unwrap();
3141 test_git(&dir, &["add", "file.txt"]);
3142 test_git(&dir, &["commit", "-q", "-m", "Initial"]);
3143 dir
3144 }
3145
3146 fn test_git(dir: &Path, args: &[&str]) {
3147 let out = Command::new("git")
3148 .args(args)
3149 .current_dir(dir)
3150 .output()
3151 .unwrap();
3152 assert!(
3153 out.status.success(),
3154 "git {args:?} failed: {}",
3155 String::from_utf8_lossy(&out.stderr)
3156 );
3157 }
3158
3159 /// The wait loop used to read the pipes only after the child exited, so a
3160 /// command that outgrew the pipe buffer blocked on the write, we blocked on
3161 /// the child, and the whole thing sat there until the 120-second timeout.
3162 #[test]
3163 fn run_command_survives_more_output_than_a_pipe_buffer_holds() {
3164 let dir = std::env::temp_dir();
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 let line = "x".repeat(199);
3169 // ~400 KB, well past any platform's pipe buffer. It also crosses
3170 // COMMAND_OUTPUT_LIMIT, so the result is truncated; the point of the
3171 // test is that the command finishes at all.
3172 #[cfg(unix)]
3173 let command = format!("for i in $(seq 1 2000); do echo {line}; done");
3174 // cmd has no seq; echo is a builtin so 2000 iterations stay fast.
3175 #[cfg(windows)]
3176 let command = format!("for /L %i in (1,1,2000) do @echo {line}");
3177 let args = json!({"command": command, "cwd": dir.to_str().unwrap()}).to_string();
3178
3179 let started = std::time::Instant::now();
3180 let result = exec_run_command(&args);
3181
3182 assert!(
3183 started.elapsed() < std::time::Duration::from_secs(30),
3184 "run_command took {:?}; it deadlocked on the pipe",
3185 started.elapsed()
3186 );
3187 assert!(
3188 result.contains("Exit code 0"),
3189 "unexpected result: {result}"
3190 );
3191 assert!(
3192 result.contains("output truncated"),
3193 "expected the 50000-byte cap to bite: {result}"
3194 );
3195 }
3196
3197 /// A command that wants input gets EOF instead of blocking forever on a
3198 /// pipe nobody is going to write to (in ACP mode, the editor's own).
3199 #[test]
3200 fn run_command_gives_a_command_that_reads_stdin_an_empty_one() {
3201 let dir = std::env::temp_dir();
3202 let args = json!({"command": "cat", "cwd": dir.to_str().unwrap()}).to_string();
3203
3204 let started = std::time::Instant::now();
3205 let result = exec_run_command(&args);
3206
3207 assert!(
3208 started.elapsed() < std::time::Duration::from_secs(30),
3209 "run_command blocked on stdin for {:?}",
3210 started.elapsed()
3211 );
3212 assert!(result.contains("no output"), "unexpected result: {result}");
3213 }
3214
3215 #[test]
3216 fn run_command_appends_co_author_trailer_to_new_commits() {
3217 let dir = init_test_repo("coauthor_append");
3218 fs::write(dir.join("file.txt"), "two\n").unwrap();
3219 // Quote-free command: `cmd /C` does not strip double quotes the way
3220 // `sh -c` does, so quoted arguments would break on Windows.
3221 let args = serde_json::json!({
3222 "command": "git add file.txt && git commit -m Update",
3223 "cwd": dir.display().to_string(),
3224 })
3225 .to_string();
3226
3227 let result = exec_run_command(&args);
3228 assert!(result.contains("co-author trailer"), "got: {result}");
3229
3230 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
3231 assert!(
3232 message.ends_with(COMMIT_CO_AUTHOR_TRAILER),
3233 "trailer must be the last line: {message:?}"
3234 );
3235 assert!(
3236 message.contains(&format!("\n\n{COMMIT_CO_AUTHOR_TRAILER}")),
3237 "trailer needs a blank line before it for GitHub to detect it: {message:?}"
3238 );
3239 let _ = fs::remove_dir_all(&dir);
3240 }
3241
3242 #[test]
3243 fn run_command_keeps_existing_co_author_trailer() {
3244 let dir = init_test_repo("coauthor_present");
3245 fs::write(dir.join("file.txt"), "two\n").unwrap();
3246 // The trailer contains spaces and angle brackets, which `cmd /C`
3247 // mis-tokenizes (`<` is redirection), so create the trailer-carrying
3248 // commit with direct git args and let run_command amend it without
3249 // editing: HEAD changes, the message already has the trailer, and the
3250 // gate must leave it alone.
3251 test_git(&dir, &["add", "file.txt"]);
3252 test_git(
3253 &dir,
3254 &[
3255 "commit",
3256 "-q",
3257 "-m",
3258 &format!("Update file\n\n{COMMIT_CO_AUTHOR_TRAILER}"),
3259 ],
3260 );
3261 let args = serde_json::json!({
3262 "command": "git commit --amend --no-edit",
3263 "cwd": dir.display().to_string(),
3264 })
3265 .to_string();
3266
3267 let result = exec_run_command(&args);
3268 assert!(
3269 !result.contains("[siGit Code]"),
3270 "no amend expected: {result}"
3271 );
3272
3273 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
3274 assert_eq!(
3275 message.matches("Co-Authored-By: siGit Code").count(),
3276 1,
3277 "trailer must not be duplicated: {message:?}"
3278 );
3279 let _ = fs::remove_dir_all(&dir);
3280 }
3281
3282 #[test]
3283 fn run_command_never_amends_pushed_commits() {
3284 let dir = init_test_repo("coauthor_pushed");
3285 let remote = std::env::temp_dir().join(format!(
3286 "sigit_test_coauthor_remote_{}.git",
3287 std::process::id()
3288 ));
3289 let _ = fs::remove_dir_all(&remote);
3290 fs::create_dir_all(&remote).unwrap();
3291 test_git(&remote, &["init", "-q", "--bare"]);
3292 test_git(&dir, &["remote", "add", "origin", remote.to_str().unwrap()]);
3293
3294 fs::write(dir.join("file.txt"), "two\n").unwrap();
3295 let args = serde_json::json!({
3296 "command": "git add file.txt && git commit -m Update && git push -q origin main",
3297 "cwd": dir.display().to_string(),
3298 })
3299 .to_string();
3300
3301 let result = exec_run_command(&args);
3302 assert!(
3303 !result.contains("[siGit Code]"),
3304 "no amend expected: {result}"
3305 );
3306
3307 // Already on the remote when the gate ran, so it must be untouched.
3308 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
3309 assert!(
3310 !message.contains("Co-Authored-By"),
3311 "pushed commit must not be rewritten: {message:?}"
3312 );
3313 let _ = fs::remove_dir_all(&dir);
3314 let _ = fs::remove_dir_all(&remote);
3315 }
3316
3317 #[test]
3318 fn test_run_command_failure() {
3319 #[cfg(unix)]
3320 let command = "false";
3321 #[cfg(windows)]
3322 let command = "exit /b 1";
3323
3324 let args = serde_json::json!({ "command": command }).to_string();
3325 let result = exec_run_command(&args);
3326 assert!(result.contains("failed"), "got: {result}");
3327 }
3328
3329 #[test]
3330 fn test_run_command_with_cwd() {
3331 let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
3332 let _ = fs::remove_dir_all(&dir);
3333 fs::create_dir_all(&dir).unwrap();
3334
3335 #[cfg(unix)]
3336 let command = "pwd";
3337 #[cfg(windows)]
3338 let command = "cd";
3339
3340 let args = serde_json::json!({
3341 "command": command,
3342 "cwd": dir
3343 })
3344 .to_string();
3345 let result = exec_run_command(&args);
3346 // The output should contain the temp dir path.
3347 assert!(
3348 result.contains(&dir.to_string_lossy().to_string()),
3349 "got: {result}"
3350 );
3351
3352 let _ = fs::remove_dir_all(&dir);
3353 }
3354
3355 #[test]
3356 fn test_run_command_bad_cwd() {
3357 let missing_dir = std::env::temp_dir().join("sigit_no_such_dir_xyz");
3358 let _ = fs::remove_dir_all(&missing_dir);
3359
3360 let args = serde_json::json!({
3361 "command": "echo hi",
3362 "cwd": missing_dir
3363 })
3364 .to_string();
3365 let result = exec_run_command(&args);
3366 assert!(result.contains("does not exist"), "got: {result}");
3367 }
3368
3369 #[test]
3370 fn test_run_command_captures_stderr() {
3371 #[cfg(unix)]
3372 let command = "echo err >&2";
3373 #[cfg(windows)]
3374 let command = "echo err 1>&2";
3375
3376 let args = serde_json::json!({ "command": command }).to_string();
3377 let result = exec_run_command(&args);
3378 assert!(result.contains("err"), "got: {result}");
3379 }
3380
3381 // ── background command tests ─────────────────────────────────────────
3382
3383 /// Extract the task id from a "Started background task {id}: ..." result.
3384 fn background_task_id(result: &str) -> u64 {
3385 let digits: String = result
3386 .strip_prefix("Started background task ")
3387 .unwrap_or_else(|| panic!("unexpected spawn result: {result}"))
3388 .chars()
3389 .take_while(char::is_ascii_digit)
3390 .collect();
3391 digits.parse().expect("task id")
3392 }
3393
3394 /// Whether the task's child has exited, without draining its output.
3395 fn background_task_exited(task_id: u64) -> bool {
3396 let mut map = lock_tasks();
3397 match map.get_mut(&task_id) {
3398 Some(task) => poll_exit_code(task).is_some(),
3399 None => true,
3400 }
3401 }
3402
3403 /// Wait (bounded) for a background task's child to exit.
3404 fn wait_for_background_exit(task_id: u64) {
3405 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
3406 while !background_task_exited(task_id) {
3407 assert!(
3408 std::time::Instant::now() < deadline,
3409 "task {task_id} did not exit in time"
3410 );
3411 std::thread::sleep(std::time::Duration::from_millis(50));
3412 }
3413 }
3414
3415 #[test]
3416 fn test_run_command_background_lifecycle() {
3417 #[cfg(unix)]
3418 let command = "echo start; sleep 2; echo done";
3419 #[cfg(windows)]
3420 let command = "echo start&& ping -n 3 127.0.0.1 > nul&& echo done";
3421
3422 let args = serde_json::json!({
3423 "command": command,
3424 "cwd": std::env::temp_dir(),
3425 "run_in_background": true
3426 })
3427 .to_string();
3428 let spawned = std::time::Instant::now();
3429 let result = exec_run_command(&args);
3430 // Spawning must return immediately, not wait the ~2s the command takes.
3431 assert!(
3432 spawned.elapsed() < std::time::Duration::from_secs(1),
3433 "background spawn blocked for {:?}",
3434 spawned.elapsed()
3435 );
3436 assert!(result.contains("command_output"), "got: {result}");
3437 let task_id = background_task_id(&result);
3438
3439 // Polling while the command is still sleeping reports it as running.
3440 let poll_args = serde_json::json!({ "task_id": task_id }).to_string();
3441 let poll = exec_command_output(&poll_args);
3442 assert!(poll.contains("still running"), "got: {poll}");
3443 let mut combined = poll;
3444
3445 wait_for_background_exit(task_id);
3446 // Grace period so the reader threads finish draining the pipes.
3447 std::thread::sleep(std::time::Duration::from_millis(300));
3448 let final_poll = exec_command_output(&poll_args);
3449 assert!(
3450 final_poll.contains("exited with code 0"),
3451 "got: {final_poll}"
3452 );
3453 combined.push_str(&final_poll);
3454
3455 // Across the polls, all output the command printed was delivered.
3456 assert!(combined.contains("start"), "got: {combined}");
3457 assert!(combined.contains("done"), "got: {combined}");
3458 }
3459
3460 #[test]
3461 fn test_kill_command_stops_background_task() {
3462 #[cfg(unix)]
3463 let command = "sleep 30";
3464 #[cfg(windows)]
3465 let command = "ping -n 31 127.0.0.1 > nul";
3466
3467 let args = serde_json::json!({
3468 "command": command,
3469 "cwd": std::env::temp_dir(),
3470 "run_in_background": true
3471 })
3472 .to_string();
3473 let result = exec_run_command(&args);
3474 let task_id = background_task_id(&result);
3475
3476 let kill_args = serde_json::json!({ "task_id": task_id }).to_string();
3477 let killed_at = std::time::Instant::now();
3478 let kill_result = exec_kill_command(&kill_args);
3479 assert!(
3480 kill_result.contains(&format!("Killed task {task_id}")),
3481 "got: {kill_result}"
3482 );
3483 // The kill must not wait out the 30s sleep.
3484 assert!(
3485 killed_at.elapsed() < std::time::Duration::from_secs(5),
3486 "kill blocked for {:?}",
3487 killed_at.elapsed()
3488 );
3489
3490 // A later poll reports the task as killed, not still running.
3491 let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
3492 assert!(poll.contains("was killed"), "got: {poll}");
3493 }
3494
3495 #[test]
3496 fn test_command_output_unknown_task() {
3497 let result = exec_command_output(r#"{"task_id": 9999999}"#);
3498 assert!(
3499 result.contains("no background task with id"),
3500 "got: {result}"
3501 );
3502 }
3503
3504 // ── task (subagent) tests ────────────────────────────────────────────
3505
3506 #[test]
3507 fn test_subagent_toolset_is_read_only() {
3508 let specs = subagent_tool_specs(&effective_subagent_tools(None).unwrap());
3509 let mut names: Vec<&str> = specs.iter().map(|spec| spec.name.as_str()).collect();
3510 names.sort_unstable();
3511 assert_eq!(
3512 names,
3513 [
3514 "glob",
3515 "list_directory",
3516 "read_file",
3517 "read_website",
3518 "search_files"
3519 ]
3520 );
3521
3522 // No recursion and no mutating tools, ever.
3523 assert!(!names.contains(&TASK_TOOL_NAME));
3524 for banned in [
3525 "create_file",
3526 "create_directory",
3527 "edit_file",
3528 "multi_edit",
3529 "delete_file",
3530 "run_command",
3531 "write_todos",
3532 "remember",
3533 ] {
3534 assert!(!names.contains(&banned), "{banned} leaked into subagent");
3535 }
3536
3537 // Every spec came from `all_tools()` (schemas intact).
3538 for spec in &specs {
3539 assert!(
3540 serde_json::from_str::<Value>(&spec.parameters_schema)
3541 .unwrap()
3542 .is_object()
3543 );
3544 }
3545 }
3546
3547 #[tokio::test]
3548 async fn test_task_reports_unavailable_without_factory() {
3549 let args = serde_json::json!({
3550 "description": "look around",
3551 "prompt": "What is in the current directory?"
3552 })
3553 .to_string();
3554 // `None` is exactly what `exec_task` passes before any surface has
3555 // registered the process-global factory.
3556 let result = exec_task_with(&args, None).await;
3557 assert!(
3558 result.contains("not available on-device yet"),
3559 "got: {result}"
3560 );
3561 }
3562
3563 #[test]
3564 fn test_kill_command_unknown_task() {
3565 let result = exec_kill_command(r#"{"task_id": 9999999}"#);
3566 assert!(
3567 result.contains("no background task with id"),
3568 "got: {result}"
3569 );
3570 }
3571
3572 #[tokio::test]
3573 async fn test_task_missing_prompt() {
3574 let result = exec_task_with(r#"{"description": "x"}"#, None).await;
3575 assert!(
3576 result.contains("missing required parameter \"prompt\""),
3577 "got: {result}"
3578 );
3579 }
3580
3581 // ── web_search tests ─────────────────────────────────────────────────
3582
3583 #[test]
3584 fn test_web_search_tool_spec_matches_the_cloud_mcp_tools_contract() {
3585 let spec = web_search_tool_spec();
3586 assert_eq!(spec.name, "web_search");
3587 let schema: Value = serde_json::from_str(&spec.parameters_schema).unwrap();
3588 assert_eq!(schema["required"], serde_json::json!(["query"]));
3589 assert!(schema["properties"]["query"].is_object());
3590 assert!(schema["properties"]["count"].is_object());
3591 }
3592
3593 #[test]
3594 fn test_is_web_search_delegate() {
3595 assert!(is_web_search_delegate("mcp__sigit__web_search"));
3596 assert!(!is_web_search_delegate("web_search"));
3597 assert!(!is_web_search_delegate("mcp__other__web_search"));
3598 }
3599
3600 #[test]
3601 fn test_web_search_unavailable_without_mcp_init() {
3602 // `mcp::init()` is never called anywhere in this test binary (it does
3603 // real network I/O and is a one-shot `OnceLock`, same constraint as
3604 // `SUBAGENT_FACTORY`), so the official server's tools — including
3605 // web_search — are never discovered here. This proves the
3606 // conditional-advertisement gate fails closed, matching a
3607 // signed-out/MCP-disabled session, rather than defaulting to "always
3608 // offered".
3609 assert!(!web_search_available());
3610 }
3611
3612 #[tokio::test]
3613 async fn test_web_search_delegates_to_mcp_and_degrades_gracefully_when_uninitialized() {
3614 // With MCP uninitialized (see the test above), `mcp::call_tool`
3615 // returns its own in-band error string rather than panicking — proves
3616 // execute_tool's WEB_SEARCH_TOOL_NAME arm actually reaches
3617 // mcp::call_tool(WEB_SEARCH_MCP_DELEGATE, ...) instead of, say, silently
3618 // no-op'ing or hitting the `Unknown tool` fallback.
3619 let args = serde_json::json!({ "query": "siGit Code" }).to_string();
3620 let result = execute_tool(WEB_SEARCH_TOOL_NAME, &args).await;
3621 assert_eq!(result, "Error: MCP is not initialized.");
3622 }
3623
3624 #[test]
3625 fn test_command_output_missing_task_id() {
3626 let result = exec_command_output("{}");
3627 assert!(
3628 result.contains("missing required parameter"),
3629 "got: {result}"
3630 );
3631 }
3632
3633 #[test]
3634 fn test_background_output_is_capped() {
3635 // Print well over COMMAND_OUTPUT_LIMIT (50 000) bytes without polling,
3636 // so the buffer must drop the oldest output and note the truncation.
3637 #[cfg(unix)]
3638 let command = r#"i=0; while [ $i -lt 200 ]; do printf 'line-%04d %s\n' "$i" \
3639 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3640 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3641 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3642 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3643 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; \
3644 i=$((i+1)); done"#;
3645 // Quote-free on purpose: `cmd /C` does not strip double quotes the
3646 // way `sh -c` does, so a quoted PowerShell one-liner gets mangled.
3647 // A cmd-native for /L loop needs no quoting at all.
3648 #[cfg(windows)]
3649 let command = &format!(
3650 "for /L %i in (1,1,200) do @echo line-%i-end {}",
3651 "a".repeat(330)
3652 );
3653
3654 let args = serde_json::json!({
3655 "command": command,
3656 "cwd": std::env::temp_dir(),
3657 "run_in_background": true
3658 })
3659 .to_string();
3660 let result = exec_run_command(&args);
3661 let task_id = background_task_id(&result);
3662
3663 wait_for_background_exit(task_id);
3664 // Grace period so the reader threads finish draining the pipes.
3665 std::thread::sleep(std::time::Duration::from_millis(300));
3666
3667 let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
3668 assert!(poll.contains("exited with code 0"), "got: {poll}");
3669 assert!(
3670 poll.contains("earlier output was dropped"),
3671 "expected truncation note, got: {poll}"
3672 );
3673 // The oldest lines were dropped; the newest survived. The two
3674 // platforms number lines differently (printf %04d vs cmd's %i).
3675 #[cfg(unix)]
3676 let (oldest, newest) = ("line-0000", "line-0199");
3677 #[cfg(windows)]
3678 let (oldest, newest) = ("line-1-end", "line-200-end");
3679 assert!(!poll.contains(oldest), "oldest output not dropped");
3680 assert!(poll.contains(newest), "newest output missing");
3681 // Buffer stayed within the cap, plus the framing: the status line
3682 // (which quotes the command) and the truncation note.
3683 assert!(
3684 poll.len() <= COMMAND_OUTPUT_LIMIT + command.len() + 500,
3685 "poll result too large: {} bytes",
3686 poll.len()
3687 );
3688 }
3689
3690 // ── task (subagent) end-to-end against a scripted endpoint ──────────
3691
3692 /// A completion whose assistant message requests one tool call.
3693 fn completion_tool_call(id: &str, name: &str, arguments: &str) -> String {
3694 serde_json::json!({
3695 "choices": [{"message": {
3696 "role": "assistant",
3697 "content": serde_json::Value::Null,
3698 "tool_calls": [{
3699 "id": id,
3700 "type": "function",
3701 "function": {"name": name, "arguments": arguments},
3702 }],
3703 }}]
3704 })
3705 .to_string()
3706 }
3707
3708 /// A completion whose assistant message is a plain text answer.
3709 fn completion_text(text: &str) -> String {
3710 serde_json::json!({
3711 "choices": [{"message": {"role": "assistant", "content": text}}]
3712 })
3713 .to_string()
3714 }
3715
3716 /// Minimal scripted OpenAI-compatible endpoint (same pattern as
3717 /// `tests/acp_permissions.rs`): serves one canned chat-completion JSON body
3718 /// per request and records each request body. The subagent loop passes
3719 /// `sink: None`, so the backend takes the non-streaming path and expects
3720 /// plain JSON rather than SSE.
3721 fn start_scripted_endpoint(responses: Vec<String>) -> (u16, Arc<std::sync::Mutex<Vec<Value>>>) {
3722 use std::io::{BufRead, BufReader, Read, Write};
3723
3724 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind endpoint");
3725 let port = listener.local_addr().unwrap().port();
3726 let requests: Arc<std::sync::Mutex<Vec<Value>>> = Arc::default();
3727 let recorded = Arc::clone(&requests);
3728 let queue = std::sync::Mutex::new(std::collections::VecDeque::from(responses));
3729
3730 std::thread::spawn(move || {
3731 // `connection: close` means one request per connection, matching
3732 // the backend's serial completion requests.
3733 for stream in listener.incoming() {
3734 let Ok(mut stream) = stream else { continue };
3735 let mut reader = BufReader::new(match stream.try_clone() {
3736 Ok(clone) => clone,
3737 Err(_) => continue,
3738 });
3739 let mut content_length = 0usize;
3740 loop {
3741 let mut line = String::new();
3742 if reader.read_line(&mut line).unwrap_or(0) == 0 {
3743 break;
3744 }
3745 let line = line.trim();
3746 if line.is_empty() {
3747 break;
3748 }
3749 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:")
3750 {
3751 content_length = length.trim().parse().unwrap_or(0);
3752 }
3753 }
3754 let mut body = vec![0u8; content_length];
3755 if reader.read_exact(&mut body).is_err() {
3756 continue;
3757 }
3758 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
3759 recorded.lock().unwrap().push(request);
3760 }
3761 let payload = queue
3762 .lock()
3763 .unwrap()
3764 .pop_front()
3765 .unwrap_or_else(|| completion_text("out of scripted responses"));
3766 let response = format!(
3767 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
3768 content-length: {}\r\nconnection: close\r\n\r\n{}",
3769 payload.len(),
3770 payload
3771 );
3772 let _ = stream.write_all(response.as_bytes());
3773 }
3774 });
3775
3776 (port, requests)
3777 }
3778
3779 #[tokio::test]
3780 #[allow(clippy::await_holding_lock)] // see the ENV_TEST_LOCK comment inside
3781 async fn test_task_runs_subagent_end_to_end() {
3782 // A file only the subagent's read_file call can surface.
3783 let dir = std::env::temp_dir().join("sigit_test_subagent_e2e");
3784 let _ = fs::remove_dir_all(&dir);
3785 fs::create_dir_all(&dir).unwrap();
3786 let file = dir.join("notes.txt");
3787 fs::write(&file, "subagent secret: 4217").unwrap();
3788
3789 // Script: one read_file tool call, a final text answer, then a third
3790 // response for the custom-agent-type phase further down.
3791 let (port, requests) = start_scripted_endpoint(vec![
3792 completion_tool_call(
3793 "call_1",
3794 "read_file",
3795 &serde_json::json!({ "path": file }).to_string(),
3796 ),
3797 completion_text("The file contains the number 4217."),
3798 completion_text("Custom type answer."),
3799 ]);
3800
3801 // Register the real process-global factory, pointing a fresh
3802 // OpenAiBackend at the scripted endpoint — exactly what the surfaces
3803 // do at startup. This is the only test that touches the OnceLock
3804 // (it's a `OnceLock`: later `set_subagent_factory` calls from other
3805 // tests are silently ignored, so every test needing a real factory
3806 // has to share this one registration — see the custom-agent-type
3807 // phase later in this same test).
3808 set_subagent_factory(Box::new(move |system_prompt: &str| {
3809 Some(Arc::new(crate::backend::OpenAiBackend::new(
3810 format!("http://127.0.0.1:{port}"),
3811 "test-key",
3812 "scripted-model",
3813 Some(system_prompt.to_string()),
3814 )) as Arc<dyn InferenceBackend>)
3815 }));
3816 assert!(subagent_available());
3817
3818 let args = serde_json::json!({
3819 "description": "read the notes file",
3820 "prompt": format!("What number is recorded in {}?", file.display()),
3821 })
3822 .to_string();
3823 let result = execute_tool(TASK_TOOL_NAME, &args).await;
3824
3825 // Only the subagent's final text comes back.
3826 assert_eq!(result, "The file contains the number 4217.");
3827
3828 // Scoped so the `MutexGuard` is dropped before phase 2's `.await` —
3829 // holding a `std::sync::MutexGuard` across an await point is a bug
3830 // (it can deadlock or block the executor), and clippy's
3831 // `await_holding_lock` lint doesn't reliably see through a manual
3832 // `drop()` call, so use an explicit block instead.
3833 {
3834 let recorded = requests.lock().unwrap();
3835 assert_eq!(recorded.len(), 2, "expected exactly two completions");
3836
3837 // The subagent conversation is fresh (subagent system prompt) and
3838 // was offered only the read-only toolset.
3839 let first = &recorded[0];
3840 assert_eq!(first["messages"][0]["role"], "system");
3841 assert!(
3842 first["messages"][0]["content"]
3843 .as_str()
3844 .unwrap()
3845 .contains("research subagent")
3846 );
3847 let offered: Vec<&str> = first["tools"]
3848 .as_array()
3849 .unwrap()
3850 .iter()
3851 .map(|tool| tool["function"]["name"].as_str().unwrap())
3852 .collect();
3853 for name in &offered {
3854 assert!(
3855 SUBAGENT_TOOL_NAMES.contains(name),
3856 "non-read-only tool offered: {name}"
3857 );
3858 }
3859 assert!(!offered.contains(&TASK_TOOL_NAME));
3860
3861 // The read-only tool actually executed: its output travelled back
3862 // to the endpoint as a tool result on the second request.
3863 let messages = recorded[1]["messages"].as_array().unwrap();
3864 let tool_message = messages
3865 .iter()
3866 .find(|message| message["role"] == "tool")
3867 .expect("no tool result in second request");
3868 assert_eq!(tool_message["tool_call_id"], "call_1");
3869 assert!(
3870 tool_message["content"]
3871 .as_str()
3872 .unwrap()
3873 .contains("subagent secret: 4217")
3874 );
3875 }
3876
3877 // ── Phase 2: a configured subagent type, on the same registered
3878 // factory and endpoint (the third scripted response above). Proves
3879 // `subagent_type` actually swaps the system prompt and narrows the
3880 // offered toolset end to end, not just at the pure-function level
3881 // (see `effective_subagent_tools_*` below for that).
3882 let agents_root = dir.join(".sigit").join("agents");
3883 fs::create_dir_all(&agents_root).unwrap();
3884 fs::write(
3885 agents_root.join("notes-reader.md"),
3886 "---\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",
3887 )
3888 .unwrap();
3889
3890 // Serialize the cwd change against the other discovery tests
3891 // (skills/commands/subagents) — the cwd is process-global and cargo
3892 // runs tests in parallel. The guard is held across the `.await`
3893 // below on purpose: the cwd must stay pinned while `execute_tool`
3894 // runs agent-type discovery. That's safe here — each `#[tokio::test]`
3895 // gets its own single-threaded runtime on its own test thread, so no
3896 // other task on this executor can contend for the lock — hence the
3897 // `await_holding_lock` allow on this test.
3898 let _env_guard = crate::ENV_TEST_LOCK
3899 .lock()
3900 .unwrap_or_else(|poisoned| poisoned.into_inner());
3901 let prev_cwd = std::env::current_dir().unwrap();
3902 std::env::set_current_dir(&dir).unwrap();
3903
3904 let args = serde_json::json!({
3905 "description": "read a note",
3906 "prompt": "What does the notes file say?",
3907 "subagent_type": "notes-reader",
3908 })
3909 .to_string();
3910 let result = execute_tool(TASK_TOOL_NAME, &args).await;
3911 assert_eq!(result, "Custom type answer.");
3912
3913 let recorded = requests.lock().unwrap();
3914 assert_eq!(recorded.len(), 3, "expected a third recorded request");
3915 let last = &recorded[2];
3916 assert_eq!(last["messages"][0]["role"], "system");
3917 assert_eq!(
3918 last["messages"][0]["content"].as_str().unwrap(),
3919 "You only ever read notes files and report exactly what they say."
3920 );
3921 let offered: Vec<&str> = last["tools"]
3922 .as_array()
3923 .unwrap()
3924 .iter()
3925 .map(|tool| tool["function"]["name"].as_str().unwrap())
3926 .collect();
3927 assert_eq!(
3928 offered,
3929 vec!["read_file"],
3930 "notes-reader's tools: read_file should narrow the offered toolset to just that"
3931 );
3932 drop(recorded);
3933
3934 std::env::set_current_dir(prev_cwd).unwrap();
3935 let _ = fs::remove_dir_all(&dir);
3936 }
3937
3938 #[test]
3939 fn effective_subagent_tools_defaults_to_full_read_only_set_without_a_type() {
3940 let mut tools = effective_subagent_tools(None).unwrap();
3941 tools.sort();
3942 let mut expected: Vec<String> = SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect();
3943 expected.sort();
3944 assert_eq!(tools, expected);
3945 }
3946
3947 #[test]
3948 fn effective_subagent_tools_inherits_full_set_when_type_omits_tools_field() {
3949 let agent_type = subagents::AgentType {
3950 name: "generalist".to_string(),
3951 description: "does anything".to_string(),
3952 tools: None,
3953 system_prompt: "Be helpful.".to_string(),
3954 path: PathBuf::from("/x/generalist.md"),
3955 };
3956 let mut tools = effective_subagent_tools(Some(&agent_type)).unwrap();
3957 tools.sort();
3958 let mut expected: Vec<String> = SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect();
3959 expected.sort();
3960 assert_eq!(tools, expected);
3961 }
3962
3963 #[test]
3964 fn effective_subagent_tools_narrows_to_the_types_allow_list() {
3965 let agent_type = subagents::AgentType {
3966 name: "notes-reader".to_string(),
3967 description: "reads notes".to_string(),
3968 tools: Some(vec!["read_file".to_string(), "glob".to_string()]),
3969 system_prompt: "Read notes.".to_string(),
3970 path: PathBuf::from("/x/notes-reader.md"),
3971 };
3972 let tools = effective_subagent_tools(Some(&agent_type)).unwrap();
3973 assert_eq!(tools, vec!["read_file".to_string(), "glob".to_string()]);
3974 }
3975
3976 #[test]
3977 fn effective_subagent_tools_drops_anything_outside_the_read_only_ceiling() {
3978 // The security-critical case: a type file cannot grant itself a
3979 // mutating tool by listing it in `tools:` — it's silently dropped,
3980 // never passed through to the offered/gated toolset.
3981 let agent_type = subagents::AgentType {
3982 name: "sneaky".to_string(),
3983 description: "tries to grant itself write access".to_string(),
3984 tools: Some(vec![
3985 "read_file".to_string(),
3986 "edit_file".to_string(),
3987 "run_command".to_string(),
3988 "task".to_string(),
3989 ]),
3990 system_prompt: "...".to_string(),
3991 path: PathBuf::from("/x/sneaky.md"),
3992 };
3993 let tools = effective_subagent_tools(Some(&agent_type)).unwrap();
3994 assert_eq!(tools, vec!["read_file".to_string()]);
3995 assert!(!tools.contains(&"edit_file".to_string()));
3996 assert!(!tools.contains(&"run_command".to_string()));
3997 assert!(!tools.contains(&"task".to_string()));
3998 }
3999
4000 #[test]
4001 fn effective_subagent_tools_errors_when_allow_list_intersects_to_nothing() {
4002 // A type whose `tools:` names only out-of-ceiling tools (e.g. Claude
4003 // Code names carried over) must error, not silently run toolless.
4004 let agent_type = subagents::AgentType {
4005 name: "misconfigured".to_string(),
4006 description: "names only invalid tools".to_string(),
4007 tools: Some(vec!["grep".to_string(), "bash".to_string()]),
4008 system_prompt: "...".to_string(),
4009 path: PathBuf::from("/x/misconfigured.md"),
4010 };
4011 let err = effective_subagent_tools(Some(&agent_type)).unwrap_err();
4012 assert!(
4013 err.contains("misconfigured"),
4014 "message names the type: {err}"
4015 );
4016 assert!(err.contains("read-only set"), "message explains why: {err}");
4017 }
4018 }