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